密西根大学每个人的-C-语言笔记-全-

密西根大学每个人的 C 语言笔记(全)

001:欢迎学习C语言编程 👋

在本节课中,我们将要学习C语言编程课程的概述,了解C语言的历史背景、重要性以及本课程的教学目标。

欢迎来到《给所有人的C语言编程》课程。我是查尔斯·塞弗里奇,本课程的讲师。

本课程及配套网站致力于学习源自1978年经典著作的C语言版本。这本书由布莱恩·W·克尼汉和丹尼斯·M·里奇合著。该书将读者置于20世纪70年代,那个计算机科学从以硬件为中心转向专注于编写可移植且高效软件的时代。

C语言被用于开发诸如Unix、Minix和Linux等操作系统。像Perl、Python、Java、JavaScript和Ruby等编程语言本身都是用C语言编写的。使得互联网成为可能的早期TCP/IP网络协议栈实现软件是用C语言编写的。最早的网页浏览器和网络服务器也是用C语言编写的。

用C语言编写软件推动了计算机体系结构和性能的重大进步。一旦我们有了针对新硬件平台的C编译器,操作系统、编译器和实用程序就可以被重新编译以在新硬件平台上运行。在过去的40年里,有如此多的软件是用C语言编写的,因此你每天使用的大部分软件很可能要么是用C语言编写的,要么是用C语言编写的编程语言编写的。

因此,我们学习C语言,与其说是为了将其作为日常使用的编程语言,不如说是将其视为现代软件和计算的基础。在许多方面,C语言相当于技术领域的罗塞塔石碑,因为它连接了过去和现在的编程语言。

网址 www.cc4e.com 中的名称“CC4E”指的是最初的Unix命令 cccc 是你用来编译C程序的命令。cc 代表C编译器。这个命令出现在K&R C书籍第一章的第一页。像我这样来自20世纪70年代和80年代的程序员,在AT&T 3B2等Unix系统上输入 cc 来编译并运行他们的第一个C语言“Hello World”程序。

本课程材料基于合理使用原则呈现,因为我们使用了已绝版且无法以任何格式广泛获取的受版权保护作品中的材料。该书也没有任何无障碍格式版本。我们在教学和研究背景下使用这些材料,重点是研究其对计算历史的贡献。这些材料免费在线提供给任何想要了解C语言、计算和计算机体系结构历史的人。

欢迎加入本课程。


本节课中,我们一起学习了C语言的历史地位和重要性。我们了解到C语言不仅是许多现代软件和编程语言的基础,也是连接计算技术过去与现在的关键。本课程将引导我们探索这门经典语言的核心。

002:1978年前后的Unix与计算发展 🕰️💻

概述

在本节课中,我们将学习C语言的历史背景,特别是1978年前后Unix操作系统与计算技术发展的关键时期。我们将了解C语言的起源、演变及其对现代编程语言的深远影响。

C语言的历史背景

C语言的历史可以追溯到更早的语言。在AT&T贝尔实验室,人们曾使用一种名为B的语言来构建实用程序和操作系统组件。但B语言过于“面向字”,不够灵活。随着支持字节寻址的新计算机硬件的出现,C语言应运而生。这种新硬件能够加载和存储字节串,而不仅仅是“字”(字通常大于一个字节,多个字符被压缩进一个字中)。在60年代末和70年代初,C语言旨在将字符作为一种核心的、低层次的数据类型。

Unix与C的共同演进

在70年代早期和中期,C语言和Unix操作系统共同演进。开发者的目标是在PDP-11/20上良好地运行Unix,同时使其能够移植到其他系统。这很大程度上得益于PDP-11/20酷炫的内存架构,特别是其字节寻址能力。他们精心地用C语言重写Unix,同时改进C语言,为Unix的可移植性奠定基础。到了1978年,K&R C(由Brian W. Kernighan和Dennis M. Ritchie合著的《C程序设计语言》)一书出版。此时,这本书可以看作是十多年来关于如何构建可移植编程语言,并用该语言构建可移植操作系统(即C和Unix)的研究总结。

C语言的标准化与演变

到了1989年,C语言已变得非常流行,标准化需求应运而生。于是出现了C89(ANSI标准),随后同一版本也被ISO(国际标准化组织)采纳为C90。这是第一个我们都能达成共识的C语言版本。ANSI标准并未试图偏离K&R C太远,而是确定了一些在当时看来需要明确的重要细节。从1990年至今,C语言持续演变,经历了数次重大修订。但现代C语言修订的关键在于,它们并不试图将C变成像Python或JavaScript那样易于使用的语言。C语言在众多编程语言中清楚自己的定位,并很好地履行了其职责。

C语言的未来与替代者

那么,未来会怎样?C语言作为一种通用编程语言使用起来较为困难。Python是优秀的通用语言,但不是优秀的系统编程语言。C语言主要缺失两点:核心类型和库中缺乏真正可靠的动态内存支持;以及没有安全的字符串类型。C语言中没有“字符串”类型,只有字符数组,而数组有固定大小。如果你试图在数组边界之外存放数据,程序就会崩溃。对我而言,C++并非C的未来版本。对于从事专业复杂系统应用开发的程序员来说,C++是更强大、更精密、更灵活的C语言版本。在某些方面,写好C++比写好C更困难。

在通用编程领域,接过C语言衣钵的语言包括Java、JavaScript、C#或Python。这些语言的关键在于,它们不将字符串视为原始的字节数组,并且提供了一个简单的面向对象层,使我们远离底层硬件。而C语言的目标是接近硬件,贴近“金属”。因此,Java、JavaScript、C#、Python都是优秀的语言,非常适合它们的应用场景,但它们并不适合编写操作系统内核。

最有可能成为“下一个C”的语言或许是Rust。Rust的理念是保持贴近硬件,同时提供一些简单且安全的核心数据类型。最近,Linux内核开始接受部分Rust代码,这意味着Rust必须足够成熟和稳定。操作系统依赖一种编程语言(如Rust)时,该语言必须非常成熟,更重要的是必须稳定。不能因为编程语言的巧妙创新而导致操作系统(如Linux)出现功能退化。所以,请关注Rust。

C语言之前的编程世界

在C语言出现之前(C语言始于1972年,书籍出版于1978年),大多数人会使用汇编语言或Fortran。有些人使用PL/I(图中未列出)。Fortran并非真正的通用编程语言,你不会用它来编写像cat这样的命令行工具。Fortran主要用于科学计算。50年代和60年代最早的计算机要么专门用于工资和人力资源系统,要么专门用于科学计算。那些用于科学计算的计算机使用Fortran,因为它是为这些旨在进行科学计算的计算机设计的合适语言。

C语言则不同,它旨在编写系统代码、内核、操作系统及其周边实用程序(包括其他语言)。因此,C语言可以说是众多衍生语言的母语,如Bash、Perl、Python、PHP、C++、JavaScript、Java、C#和Objective-C都源于C语言的开端。这就是为什么你在这些其他语言中看到许多相似的语法模式,例如JavaScript和Java都从C语言继承了它们的for循环语法。

计算机发展简史

在C语言历史之上,我们可以简要回顾一下计算机的发展史。我有一门完整的课程叫“互联网历史、技术与安全”,它从20世纪40年代开始,更侧重于通信而非计算,尽管通信与计算从40年代至今一直紧密相连。

在50年代早期,计算机最好被视为价值数百万美元的战略资产,每一台计算机都是如此,而且很多是定制的。例如,我就读的密歇根州立大学的第一台计算机是由该校电气工程专业的学生根据他们从爱荷华州借鉴的一些设计自行建造的。因此,编程语言、操作系统等都没有太多通用性或共享性。人们倾向于将代码写在纸带或后来的磁带上,然后加载运行。只要代码能运行就很开心了,不需要操作系统,这些也不是多进程计算机,软件环境非常精简。

到了50年代末和60年代,像IBM和数字设备公司这样的公司开始销售通用计算机。他们可以制造并开始销售这些计算机,虽然价格仍然昂贵,通常一个企业可能只有几台用于处理工资单等重要事务,因为计算机太贵了。

60年代,计算机组件、芯片等开始成为商品。你可以直接去一个地方购买芯片,然后通过购买一堆芯片组装成计算机。由于不需要从头开始建造一切,成本大幅降低。这些较便宜的计算机速度可能稍慢,但到60年代末,计算机数量已经很多了。有非常昂贵、特殊的小批量生产计算机,也有像前几代小型机那样大量存在的旧计算机,散落在旧的计算机科学系或不知道如何处理它们的企业里(他们想买新的)。同时,创新的低成本计算机不断涌现。

70年代,在这种新旧计算机硬件混杂的环境中,问题在于:我们能否利用所有这些旧硬件做些什么?是否存在一种通用的解决方案?这就是Unix和C出现的地方。当然,70年代之后,我们进入80年代,那是微处理器和个人计算机的时代。计算机从冰箱或桌子大小缩小到可以集成在单个芯片上。起初,像IBM PC或Commodore PET这样的个人电脑性能很差,但一旦所有东西都能集成到单个芯片上,性能就能迅速提升。并且,由于个人电脑成为大众市场产品,大量资金可以投入其中。

到了90年代,个人电脑持续发展,但通信和信息交换的需求变得重要。因此,在90年代,我们看到越来越注重通过互联网和其他网络连接计算机,计算机的性能不断提升,价格持续下降。进入21世纪,亚马逊的AWS成立于2002年,它使用英特尔等公司的个人电脑微处理器,将计算作为一种商品提供。所以,你甚至不再需要购买计算机,只需去亚马逊说,我每月花7美元租一台计算机。

因此,我们看到1978年是一个转折点:计算机变得越来越普及,价格下降,数量增多,并且种类多样。如今,计算机的种类实际上减少了。

Unix操作系统的历史

让我们看看与C语言紧密相连的操作系统——Unix。在60年代,有一个多用户操作系统叫Multics。到了70年代,他们想开发另一个操作系统,最终称之为Unix,并在PDP-11/20上运行,这是当时市场上基于商品化部件的新型计算机之一。

1973年,Unix用C语言重写,但最初只运行在PDP-11上。尽管他们从一开始就为可移植性奠定了基础,知道他们希望一切都是可移植的,但第一个版本只能先确保在PDP-11上运行。到了1978年,Unix运行的第二个计算机是Interdata 8/32,这是一台相当不同的计算机。这次移植非常成功,他们真正学到了很多关于使Unix成为可移植软件的知识。

在70年代早期,C语言的演进是为了让Unix能够被移植。这就像在PDP-11和Interdata之间遇到了问题,我们如何解决?我们可以改变操作系统的工作方式,修改操作系统代码,也可以改变C编译器,然后用更多的C语言代码重写操作系统代码,减少汇编语言的使用。目标是让Unix中只包含非常少量的汇编语言。多年来,这个比例越来越低。

Unix被多次重写,70年代出现了多个版本,都与可移植性有关。到1978年,Unix第七版(Version 7)也能运行在DEC公司全新的VAX系统架构上。

加州大学伯克利分校有自己的Unix发行版,称为BSD(伯克利软件发行版)。这非常酷,因为大学通常推动像网络、TCP/IP这样的技术。BSD Unix是许多人第一次见到TCP/IP的地方。1982年,一家完全基于Unix的公司——Sun微系统公司成立。Sun融合了斯坦福和伯克利的一些工作,全部基于Unix。他们实际上创建了Unix工作站市场。此时,你可以想象世界即将全面采用Unix。Unix是最伟大的东西,计算机科学系在80年代中期都在操作系统课程中教授Unix。

问题在于,AT&T从未真正为Unix制定一个商业计划。因此,在如何将这个极其流行的事物货币化方面,出现了一些反复和起步。他们做得并不好,花了很长时间才弄清楚什么会成功。等到AT&T理清头绪时,市场已经向前发展了。

Minix与Linux的崛起

Minix是由荷兰的Andrew Tanenbaum开发的一个操作系统。他构建了一个完全免费开源的操作系统,用于教育。他围绕它编写了一本教科书,非常受欢迎。但他当时不希望商业化,至少在那个时间点不希望。因此,他某种程度上将其抓得太紧,这又是一种知识产权上的失误。

1991年,一位名叫Linus Torvalds的程序员决定从头开始实现一个100%免费的Unix-like内核。他不打算使用Unix或Minix的代码,想创造另一个东西。最初这只是个爱好,他想看看自己能走多远。到1992年,Linux开始运行,并采用了GPL(GNU通用公共许可证)。这是一种严格的开源许可证,使得将Linux移出开源领域变得困难,这意味着人们可以放心地对Linux进行投资。因此,Linux成为了现代的Unix-like系统。Unix试图坚持了一段时间,但确实无法竞争。如今,剩余的Unix发行版只占市场的极小部分,而Linux占据了市场。

个人经历与行业变迁

回顾这一切,请记住1978年是历史上我认为一切都发生改变的转折点。我从事计算机科学工作已经很久了,始于1975年。1975年,我在一台CDC 6500计算机上学习Fortran,它运行一个特殊的操作系统叫Scope。我学习汇编、Fortran甚至Pascal。Pascal在70年代是一种宣称“未来在此”的语言(我也有关于Pascal的视频),但它主要面向教学,所以我在教育环境中使用Pascal。

当我成为80年代的专业程序员时,我使用了很多东西:汇编语言、COBOL,偶尔用点Unix和C。在80年代早期,Unix并非无处不在,至少在专业领域不是,因为PC革命正在发生。我们有DOS、早期Windows版本。我使用过dBase和Turbo Pascal,在IBM 360上教过课,也教过汇编语言。我使用过DEC VAX和VMS(非Unix)操作系统以及Fortran。然后在80年代中期,我在AT&T的Western Electric(我想是叫3B2)上教过Unix、C和Fortran。因此,尽管我们可以追溯到1978年是万物变革的时刻,但市场并非一夜改变。

然而,90年代发生的是,随着微处理器速度的提升,所有这些老牌计算机厂商(如Burroughs、Unisys、IBM、Cray、Control Data)以及许多小公司,都开始创建越来越快的Unix工作站。因为他们可以制造快速的新硬件,然后移植Unix操作系统,让操作系统在他们的新硬件上运行。这催生了一些惊人的创新。我使用过Sun、Ardent、Stellar(这些公司现在都已消失)、IBM RS/6000、Convex C240等。我的桌上曾有一台NeXT,我使用C语言,但TCP/IP、Windows、HTTP、万维网、Windows和Mac OS都混杂其中。但创新最快发生的领域确实是Unix空间。

到了21世纪,我接触的所有东西几乎都带有Unix的某些方面。我不是Windows用户,所以我接触的所有东西都带有Unix的某些方面。Mac OS仍然包含Unix,Linux也是。我使用的语言则逐渐收窄到Java、PHP和JavaScript。然后到了2010年代,Linux、Mac OS、Python、PHP、JavaScript。事情确实已经稳定在一个世界里:我自己的软件开发几乎总是在使用Unix-like系统,以及基于C或衍生自C的语言,因为Python、Java、PHP和JavaScript都基于C。这就是为什么理解C语言如此重要。我并非以专业身份编写C代码,但我每天都在运用我所有的C语言知识。

课程寄语与学习建议

那么,进入“给所有人的C语言编程”课程。我要说,C语言是你将要学习的最重要的编程语言。它绝不应该作为教给任何学生的第一门编程语言。在你的职业生涯中,你可能永远不会在专业环境中写一行C代码。我教C语言,不是为了让你成为一名专业的C程序员,我教C语言,是为了让你成为一名专业的Java程序员。但是,如果你在学习旅程的合适时机学习C语言,这是成为编程大师道路上必要的一步,而且是重要的一步。因为如果我没有先教你C语言,我就无法向你解释Java是如何工作的。

所以,这门课不仅仅是“拿个C语言证书,去赚大钱”。因为我不认为这个证书能让你赚很多钱,但我确实认为这个证书将为你作为程序员的未来打开大门。

因此,请对这门课程的材料保持耐心,不要急于求成。这是一门在线课程。我保证,你可以搜索到我创建的每一个编程练习的解决方案,我改动不大,所以解决方案就在那里。如果你的目标是取巧,只是搜索编程解决方案,那就去吧,完成课程,恭喜你。但你只是剥夺了自己学习的机会。你没有骗过我,你搜索并粘贴解决方案,我没有任何损失。

从始至终,每一个练习都试图让你在理解上向前迈进一小步。即使开头的简单练习有很多,它们也是为了让你为课程后期更具挑战性的内容做好准备和加强。如果你在课程开始时就开始粘贴解决方案,你在课程结束时将毫无机会。当你那样做时,你只是在浪费自己的时间。

我期待你学习这门课程的其余部分。当然,我也期待你告诉我,我关于这门课对你有多重要的直觉是否正确,尤其是在你学习了更多课程并找到成为专业人士的道路之后。

总结

本节课中,我们一起学习了C语言在1978年前后的历史背景,了解了它与Unix操作系统的共同演进、标准化过程以及对后世编程语言的深远影响。我们还回顾了计算机从昂贵专用设备到普及化商品的发展历程,以及Unix家族的兴衰演变。最重要的是,我们明确了学习C语言的目的:并非为了成为C语言专家,而是为了深入理解现代高级编程语言的底层原理,为成为真正的编程大师奠定坚实的基础。

003:贝尔实验室的C语言构建

在本节课中,我们将跟随布莱恩·克尼汉的讲述,了解C语言在贝尔实验室诞生的历史背景、设计初衷及其早期发展历程。我们将看到C语言是如何从一个实验性的想法,逐步演变成一门强大且高效的编程语言。

贝尔实验室的创新环境

上一节我们介绍了C语言诞生的宏观背景,本节中我们来看看孕育它的具体环境——贝尔实验室。

贝尔实验室聚集了许多聪明人。在默里山,大约有1200名博士级别的人员,基本上都在一栋巨大的建筑里工作。因此,有很多人相互接触,他们都在从事各种技术工作。

这个环境并不告诉人们该做什么。它的模式是:去做你的事,然后在一张纸的一面告诉我们你做了什么,这将决定你明年能获得多少资金。这是一个非常长的周期。额外的一点是,这是一个问题丰富的环境。因此,有很多事情你可以研究。我认为,这里有一种非常温和的引力,驱使人们去做一些别人可能关心的事情。当然,很多人不在乎别人的想法,只想做自己的事。但我认为大多数人能从中获得一些精神上的回报:我做了一些东西,交给你,你说这很棒。然后你又说“但是”,并告诉我所有还不够好的地方。这种事情在实验室里非常普遍。

因为当时的AT&T基本上为美国大部分地区提供电话服务,涉及许多人,在通信领域内外存在各种有趣的问题。所以,无论你对什么感兴趣,AT&T的某个部分都可能用得上它。这是原因之一,但外部世界也同样重要。贝尔实验室的研究社区本身就是学术研究社区的一部分。因此,既有内部需求,也有外部渠道,而实验室也非常乐意让人们从事任何一方面的工作。

所有这些,我认为运作得非常好。稳定的资金支持也起到了帮助作用,因为基本上在那时,如果你在美国打了一个长途电话(还记得长途电话的概念吗?),那笔收入中的一小部分就会资助贝尔实验室,其章程是:让服务变得更好。至于如何做到,我们不用担心细节。

编程语言的演进与需求

在了解了贝尔实验室独特的环境后,我们来看看当时的技术驱动力,特别是对新型编程语言的需求。

当时,人们对编程语言非常感兴趣。这一切都源于Multics系统的经验。贝尔实验室的人,当然还有麻省理工学院的人,已经意识到用高级语言编写程序是有意义的。接下来的问题是:什么是合适的高级语言?他们最初从PL/I开始,这在抽象意义上听起来是个好主意,但实际上是个糟糕的主意,因为它是一门糟糕的语言。

剑桥大学的马丁·理查兹有一门叫做BCPL的语言。据我了解,他在麻省理工学院度过了一个学术休假年,在某种意义上“植入”了这门语言。它比任何版本的PL/I都更简单、更清晰、更适合系统编程这类工作。

因此,贝尔实验室的肯·汤普森、丹尼斯·里奇等人获得了一些经验,认识到高级语言适合编写许多不同的东西。但BCPL并不适合现代机器,因为它是无类型的。而新出现的机器显然将支持类型,如字节、整数,可能还有更大的类型。

所以,在某个时候,肯·汤普森做了很多实验,尝试了比BCPL更简单的版本,其中一个特定的版本叫做B。它是一种解释型语言,没有编译器。它再次具有足够的表达能力,人们开始喜欢它。我基本上就是从这时开始接触的。我写过一些PL/I,很糟糕。我写过Fortran,好一些。但B语言用起来更友好一些,不过它仍然是无类型的。

C语言的诞生与设计目标

从B语言出发,为了适应新的硬件,一门更强大的语言呼之欲出。

B语言也是一个解释器,所以效率不会太高。随着PDP-11即将问世(我不记得确切的时间点),很明显,需要一个感觉上像B语言,但包含某种类型机制(以便可以处理字符或整数)的版本,这将是未来的方向。这就是丹尼斯接手并开始开发C语言及其编译器等工作的地方。

可移植性在当时是人们非常关心的问题。因为虽然Unix的核心工作是在PDP-11上完成的,但当时还有其他同等级别的机器,比如Interdata有几台732、832这样的型号。我想可能还有惠普的机器等等。另一方面,更困难的是那些大型主机类计算机,它们被本地计算中心使用。这些本质上是Multics机器的简化版本,是GE 635这类东西。所以,它们是庞大、笨重、面向字的机器,实际上是经过一些清理的IBM 7094。让一门为操作字符而设计的语言,能为这些本质上没有字符概念的机器进行合理的编译,我认为当时存在一些困难。但这种可移植性——如何让同一门语言在不同的计算机上工作——是一个核心挑战。

丹尼斯最初的编译器确实是针对PDP-11的。后来史蒂夫·约翰逊带来了可移植的C编译器,它基本上将前端分离出来:识别语言,构建一些中间结构,然后为不同类型的机器生成代码。

C语言的早期应用与标准化

随着C语言编译器的成熟,它开始被更广泛地使用和传播。

我曾为B语言写过一篇教程,因为我觉得它有趣,也许可以告诉别人如何使用它。所以当C语言开始被使用,并且我变得更擅长使用它时,我基本上重新利用了B语言的教程,将其更新并改造成了C语言教程。

那时,我基本上用C语言编写了所有我正在写的软件。我有点喜欢它,它很好,它非常符合人们思考计算的方式。我认为,它也非常匹配当时的实际硬件。你可以想象编译器在做什么,一切都很清晰。所以,它高效、富有表现力,并且与周围的一切都很好地匹配。

然后,大概在1977年最早的时候,我说服了丹尼斯写一本书。第一版在1978年问世。那时,这门语言已经相当合理了。我认为那本书正好处于结构体是否完全成为语言一部分的临界点上,有一点悬而未决。我不记得了,但我想可能还没有完全实现,但已经很接近了。由于丹尼斯同时负责编译器和书籍,这至少是一个一致的观点。问题是:你能将一个结构体作为一个单元来获取和传递吗?还是必须做一些特殊处理?但这确实是一个里程碑。

下一个里程碑可能是1988年的书和ANSI C标准的制定,这是第一个标准,时间点也大致相同。

因此,我认为这些是我衡量其发展的节点。到1988年左右,在你接触它之前,C语言对于任何你可能合理想做的事情来说,可能都已经非常完善了。

这可能是异端邪说,但我认为自那以后的演变和变化,在某种意义上并没有带来足够多的益处。


本节课中,我们一起学习了C语言在贝尔实验室的诞生故事。我们看到了一个由聪明人组成、鼓励自由探索的“问题丰富”环境如何催生创新。从Multics经验和对PL/I的失望,到BCPL和B语言的实验,最终由丹尼斯·里奇主导,为了在新型硬件(如PDP-11)上获得效率和可移植性,创造了拥有类型系统的C语言。其设计目标清晰:高效、表达力强、与硬件匹配,并能通过可移植编译器(如史蒂夫·约翰逊的PCC)在不同机器上运行。从1978年的第一本“K&R”书到1988年的ANSI标准,C语言迅速成熟,成为一门强大而稳定的系统编程语言,其早期设计理念影响深远。

004:西班牙瓦伦西亚办公时间 🎉

在本节课中,我们将回顾查克教授在西班牙瓦伦西亚举办的首次线下办公时间。这次活动是两年多以来的第一次面对面交流,查克教授与几位在线课程的学生见面,并讨论了从学习编程到获得工作的过渡经验。


大家好,我是查克。我正在大西洋中部,从西班牙返回的途中。

我在西班牙进行了两年多以来的首次线下办公时间,与我的在线学生面对面交流。我对此感到非常兴奋。

大家好,我是查克,在西班牙瓦伦西亚。这是我们两年多以来的第一次办公时间。

上一次是2019年12月在京都,我和我的朋友正司在一个卡拉OK包厢里。这也是我自疫情以来的首次国际旅行,我玩得很开心。我想让你们认识一些两年多来首次见面的同学。

那么,我们开始吧。请告诉我们你的名字。

嗨,我是赫尔曼。我很高兴来到这里。

我是杰·略。和这些人在一起真的很好。

我是查维。查克教授真的很棒。

谢谢。

嗨,我是加布里埃尔。我打算更经常地开始学习Python。

好的。

嗨,我是拉斐尔。我非常非常高兴能在这里,并向教授学习。你是一位正在学习Python的艺术家,对吗?

嗨,我叫乔安。我很高兴终于在这里见到这位将我引入编程世界的人。他教我什么是布尔逻辑。

在你学习Python之前,你是做什么的?

化学家。

你找到编程工作了吗?

是的。

你是怎么找到编程工作的?

这是我的第二份工作。不,我的意思是,你是怎么找到第一份工作的?

我参加了一个实习。

然后我担任助理管理员,后来转到了一个 .NET 开发岗位。

看,这就是我最常被问到的问题之一,也是我们在这里遇到的第一个问题:如何从掌握知识过渡到获得工作。这很困难,关键点在于,正如我在许多文章中写到的,关键是结识人脉

因为入门级工作很难找。所以,也许你可以从实习开始,或者从质量保证(QA)岗位开始,或者从一些能让你入门的工作开始。因为直接敲门获得一份入门级编程工作真的非常困难。

再次强调,我们来自瓦伦西亚,这是自疫情以来我们首次的面对面会议,希望疫情能继续好转。为大家干杯。


本节课中,我们一起回顾了查克教授在瓦伦西亚的特别办公时间。我们见到了几位学生,并探讨了一个关键问题:如何将编程知识转化为实际工作。核心建议是通过实习或入门级岗位积累经验并建立人脉,这是开启职业生涯的重要一步。

005:从Python到C——编程语言罗塞塔石碑(第一部分)

概述

在本节课中,我们将通过对比Python和C语言,快速学习C语言的基础语法。我们将从输出、输入、变量声明等基本概念开始,帮助你利用已有的Python知识来理解C语言的核心思想。


从Python到C:核心差异

上一节我们介绍了本课程的目标。本节中,我们来看看Python和C语言在设计哲学和语法上的主要区别。

Python和C虽然渊源深厚(Python本身由C语言编写),但在许多方面存在显著差异:

  • 语法:Python使用缩进(空格)来定义代码块,而C语言使用花括号 {},并忽略空格。
  • 面向对象:Python是高度面向对象的语言,内置了列表、字典等方便的数据结构。C语言则完全不面向对象,它更底层、更高效,使用结构体、指针等概念来构建复杂数据结构。
  • 内存管理:Python、Java等语言具有自动内存管理机制。C语言则需要程序员手动管理内存,这既是挑战,也带来了极高的控制权和效率。
  • 历史与定位:C语言诞生于20世纪70年代,Python诞生于80年代。你可以将Python视为建立在C语言之上的一个“便捷层”。

尽管有这些差异,两者也有很多相似之处,例如运算符(+, -, *, /, %)、比较运算符(==, !=, <, >)、变量命名规则、循环概念(break, continue)等。


输出:printprintf

在Python中,我们使用 print() 函数进行输出。它非常方便,可以自动处理不同类型的数据并在输出项之间添加空格。

以下是Python的输出示例:

print("Hello world")
print("Answer", 42)
x = 3.14159
print("x =", x)
print("Hello", "Sarah")

现在,让我们看看如何在C语言中实现相同的功能。

首先,每个C程序通常都需要包含标准输入输出库:

#include <stdio.h>

C语言使用 /* ... */ 进行多行注释(较新的标准也支持 // 单行注释)。

程序的执行从 main 函数开始:

int main() {
    // 你的代码写在这里
    return 0;
}

在C语言中,我们使用 printf 函数进行格式化输出:

printf("Hello world\n"); // \n 表示换行,必须显式添加
printf("Answer %d\n", 42); // %d 用于格式化整数
float x = 3.14159;
printf("x = %.1f\n", x); // %.1f 表示输出浮点数,保留一位小数
printf("Hello %s\n", "Sarah"); // %s 用于格式化字符串(字符数组)

核心概念

  • printf 的第一个参数是格式化字符串,其中的 %d, %f, %s格式说明符,分别对应整数、浮点数和字符串。
  • 额外的参数按顺序替换这些格式说明符。
  • 换行符 \n 必须手动添加。
  • 字符串在C语言中实际上是以空字符 \0 结尾的字符数组

输入数字:inputscanf

在Python中,我们常用 input() 读取用户输入,并使用 int() 进行类型转换。

以下是Python读取并转换数字的示例(电梯楼层转换器):

print("Enter US Floor")
usf = int(input())
euf = usf - 1
print("EU Floor", euf)

在C语言中,我们使用 scanf 函数来读取格式化输入。

以下是等效的C代码:

#include <stdio.h>

int main() {
    int usf, euf; // 必须在使用前声明变量类型
    printf("Enter US Floor\n");
    scanf("%d", &usf); // %d 读取一个整数,& 是“取地址符”
    euf = usf - 1;
    printf("EU Floor %d\n", euf);
    return 0;
}

核心概念

  • C语言中变量必须先声明类型,后使用
  • scanf 的第一个参数也是格式化字符串。%d 告诉程序读取一个整数。
  • &usf 中的 &(取地址符)至关重要。它允许 scanf 函数修改 usf 变量的值。这涉及按值传递按引用传递的概念,我们将在后续章节深入讲解。

输入字符串:简单情况

在Python中,input() 函数直接返回一整行字符串。

以下是Python读取名字并问候的示例:

print("Enter your name")
name = input()
print("Hello", name)

在C语言中,处理字符串需要更多步骤,因为字符串是字符数组。

以下是等效的C代码:

#include <stdio.h>

int main() {
    char name[100]; // 声明一个最多存储100个字符的数组
    printf("Enter your name\n");
    scanf("%99s", name); // %s 读取一个字符串(遇到空格停止),99 防止溢出
    printf("Hello %s\n", name);
    return 0;
}

核心概念

  • char name[100]; 定义了一个固定长度的字符数组。你必须预估可能的最大输入长度。
  • scanf 中的 %99s 会读取一个单词(遇到空格、制表符或换行符即停止),99 确保了即使输入过长也不会超出数组边界。
  • 注意,name 作为数组名,在传递给 scanf 时本身就代表了数组的起始地址,所以不需要在前面加 &

输入整行文本

有时我们需要读取包含空格的整行文本。在Python中,input() 本身就能做到。

以下是Python读取并回显一整行的示例:

print("Enter a line")
line = input()
print("Line:", line)

在C语言中,使用 scanf 配合一个特殊的模式可以实现类似功能。

以下是等效的C代码:

#include <stdio.h>

int main() {
    char line[1000];
    printf("Enter a line\n");
    scanf("%999[^\n]", line); // 读取直到遇到换行符 \n
    printf("Line: %s\n", line);
    return 0;
}

核心概念

  • %[^\n] 是一个扫描集说明符。[^\n] 的意思是“匹配任何不是换行符的字符”。这有效地实现了读取一整行(直到遇到回车)。
  • 999 同样是为了防止输入超出 line 数组的容量。


总结

本节课中,我们一起学习了从Python过渡到C语言的基础知识。我们比较了两种语言在输出(print/printf)、输入(input/scanf)、变量声明和字符串处理上的关键区别。C语言要求更显式、更底层的操作,例如手动管理内存、显式指定数据类型和格式化细节。通过这种“罗塞塔石碑”式的对比,我们为后续深入学习C语言的强大功能(如指针、结构体)奠定了坚实的基础。记住,本节的代码示例旨在帮助你建立初步连接,请务必动手输入和运行它们以加深理解。

006:从Python到C——编程语言罗塞塔石碑(第二部分)🚀

在本节课中,我们将继续学习如何将Python中的常见编程概念转换为C语言实现。我们将重点探讨C语言中的文件操作、循环结构、条件判断以及函数定义,并与Python中的对应概念进行比较,帮助你理解两种语言的核心差异。


文件读取与标准流

上一节我们介绍了C语言中的基本输入输出。本节中我们来看看如何在C语言中安全地读取用户输入和文件。

在C语言中,有一个更安全的方法来读取字符串输入,即使用 fgets 函数。fgets 函数的作用是:从指定的输入流中读取一行字符,直到遇到换行符或达到指定的字符数量上限。

以下是 fgets 函数的基本用法:

fgets(buffer, sizeof(buffer), stdin);

这条命令表示:从标准输入(stdin)读取最多 sizeof(buffer) 个字符,寻找换行符,并将结果存入 buffer

在C语言中,有三个基本的预定义文件流:

  • 标准输入 (stdin):通常是键盘输入,读取直到文件结束符(EOF)。
  • 标准输出 (stdout)printf 函数输出的目的地。
  • 标准错误 (stderr):用于输出错误信息,与标准输出分离。

当你仅在终端命令行中运行程序时,标准输入是你的键盘,标准输出和标准错误都显示在你的屏幕上。但如果你重定向了程序的输入和输出,错误信息通常仍会显示在屏幕上,而不会混入被重定向的标准输出流中。

fgets 是标准库的一部分。当我们使用 fgets(buffer, sizeof(buffer), stdin) 时,表示从标准输入读取。稍后我们会看到,fgets 也可以从文件中读取,其第三个参数就是文件句柄。

在C程序中,有三个预定义的文件句柄:stdinstdoutstderr。它们都是在 <stdio.h> 库中定义的常量。


读取文件内容

现在我们将学习如何在C语言中读取一个文件。在Python中,我们经常这样操作:

以下是Python读取文件的示例:

with open('romeo.txt') as hand:
    for line in hand:
        line = line.strip()
        print(line)

我们获取一个文件句柄,读取它。当然,如果文件不存在可能会失败。然后我们使用一个不确定循环(for line in hand),这是非常“Pythonic”且高效的表达方式。line.strip() 用于移除行末的换行符。这段代码会读取整个文件并打印出来。

在C语言中实现相同的功能,我们需要创建一个字符数组来存储每行内容。在Python中,文件行的长度可以是任意的,但在C语言中,我们必须预先声明能处理的最大字符数,因为我们用于读取的 line 变量是一个固定大小的数组。

以下是C语言读取文件的等效代码:

#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/de08f7b781a5b29c46451061737ced75_10.png)

int main() {
    FILE *hand; // 文件句柄,是指向FILE对象的指针
    char line[1000]; // 声明一个最多存储1000个字符的数组

    hand = fopen("romeo.txt", "r"); // 打开文件,模式为“r”(读取)
    // Python的open()函数灵感来源于C的fopen()

    while (fgets(line, sizeof(line), hand) != NULL) {
        printf("%s", line); // 打印该行
    }

    fclose(hand); // 关闭文件
    return 0;
}

FILE *hand; 声明了一个文件句柄,它是指向 FILE 结构体的指针。hand = fopen("romeo.txt", "r"); 打开文件。Python的 open 函数设计上借鉴了C的 fopen,但使用起来更简单。

我们需要自己编写 while 循环。fgets(line, sizeof(line), hand) 从文件句柄 hand 中读取最多 sizeof(line) 个字符到 line 数组中。当 fgets 遇到文件结束符(EOF)时,它会返回 NULL。因此,这个循环会读取文件直到结束。

注意,这里我们不需要像Python那样使用 strip(),因为 fgets 很“贴心”,它不会将行尾的换行符包含在读取的字符串中(实际上它会读取换行符,但通常我们会处理它)。而在Python中,如果不使用 strip(),打印时可能会出现多余的空行。


确定循环:for循环

接下来我们看看确定循环,特别是 for 循环。在Python中,range 函数可以方便地生成一个数字序列。

以下是Python的for循环示例:

for i in range(5):
    print(i)

range(5) 是一个生成器,会产生数字0到4。然后我们打印 i,输出将是0, 1, 2, 3, 4。

在C语言中,for 循环的结构包含三个由分号分隔的部分:

for (int i = 0; i < 5; i++) {
    printf("%d\n", i);
}
  1. 初始化部分 (int i = 0):在循环开始前执行一次,将变量 i 设置为0。
  2. 测试条件部分 (i < 5):在每次循环迭代开始前检查。如果条件为真,则执行循环体;如果为假,则退出循环。这是一个“顶部测试”循环。由于开始时 i 为0,小于5,所以循环至少会执行一次。
  3. 更新部分 (i++):在每次循环体执行完毕后执行,这里使用后置递增运算符将 i 的值加1。

这种 for 循环的语法在PHP、JavaScript和Java中都非常相似。循环体用花括号 {} 括起来。这两段代码(Python和C)会产生完全相同的输出。


非确定循环与条件逻辑

现在让我们看一个更复杂的例子,它涉及非确定循环和条件判断。我们将实现一个寻找最大值和最小值的程序。

在Python中,我们可能会这样写:

max_val = None
min_val = None

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/de08f7b781a5b29c46451061737ced75_20.png)

while True:
    line = input()
    line = line.strip()
    if line == 'done':
        break
    ival = int(line)
    if max_val is None or ival > max_val:
        max_val = ival
    if min_val is None or ival < min_val:
        min_val = ival

print('Maximum:', max_val)
print('Minimum:', min_val)

我们使用 while True: 创建一个无限循环(非确定循环)。读取输入行,去除首尾空白。如果输入是字符串 'done',则用 break 跳出循环。否则,将字符串转换为整数。接着,检查并更新最大值和最小值。循环结束后,打印结果。

在C语言中,等效的代码使用 scanf 来读取整数:

#include <stdio.h>

int main() {
    int max_val, min_val, v;
    int first = 1; // 用于标记是否是第一个数字

    while (scanf("%d", &v) == 1) {
        if (first) {
            max_val = min_val = v;
            first = 0;
        } else {
            if (v > max_val) max_val = v;
            if (v < min_val) min_val = v;
        }
    }

    printf("Maximum: %d\n", max_val);
    printf("Minimum: %d\n", min_val);
    return 0;
}

我们使用 scanf(“%d”, &v) 来读取一个整数到变量 v 中,& 符号表示“传引用”,以便 scanf 能够修改变量 v 的值。scanf 在成功读取一个项目时返回1,遇到文件结束或错误时返回EOF。

逻辑与Python版本类似:如果是第一个数字,同时将其设为最大值和最小值;否则,与当前最大值和最小值比较并更新。

scanf 会忽略空白字符(包括空格和换行),只寻找整数。因此,输入 5 29 9 和分三行输入 5299 的效果是一样的。使用 scanf 的版本比使用 getsfgets 然后转换更简洁,且无需担心字符数组的大小问题。


条件判断:if-else 与 else if

让我们通过一个猜数字游戏来深入理解C语言中的条件判断。在Python中,我们可以使用清晰的多路分支 if-elif-else

以下是Python的猜数字游戏示例:

while True:
    try:
        line = input()
        line = line.strip()
        guess = int(line)
        if guess == 42:
            print('Nice work!')
            break
        elif guess < 42:
            print('Too low')
        else:
            print('Too high')
    except:
        break # 遇到EOF或其他错误时退出

这是一个无限循环。我们使用 try-except 是因为 input() 在遇到EOF时不会明确返回,而是会抛出异常,我们需要捕获它并跳出循环。然后转换输入为整数。如果猜中(42),则打印信息并用 break 跳出循环(break 作用于循环,而非 if 语句)。否则,判断是太低还是太高。

在C语言中,等效的逻辑如下:

#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/de08f7b781a5b29c46451061737ced75_28.png)

int main() {
    int guess;

    while (scanf("%d", &guess) != EOF) {
        if (guess == 42) {
            printf("Nice work!\n");
            break;
        } else if (guess < 42) {
            printf("Too low\n");
        } else {
            printf("Too high\n");
        }
    }
    return 0;
}

我们使用 scanf 循环读取,直到遇到EOF。如果猜中,打印信息并 break。注意,在 if (guess == 42) 后面,我们使用了花括号 {},因为这是一个包含两个语句(printfbreak)的代码块。当 ifelse 后面跟有多条语句时,必须使用花括号。

现代程序员即使对于单条语句也倾向于使用花括号以增强清晰度和避免错误。但严格来说,如果 ifelse 后面只有一条语句,花括号不是语法必需的。本例中,else if 和最后一个 else 后面的 printf 都是单条语句。

一个非常重要的概念是区分 else ifelif

  • 在Python中,if-elif-else 是一个真正的多路分支结构,它们属于同一个代码块。
  • 在C语言中,并没有 elif 这个关键字。else if 实际上是两个关键字:一个 else 后面紧跟了一个 if 语句。

从结构上看,C语言的代码更像是:

if (guess == 42) {
    // 代码块1
} else { // 第一个if的else子句开始
    if (guess < 42) { // else子句内嵌套了一个新的if
        // 代码块2
    } else { // 嵌套if的else子句
        // 代码块3
    }
} // 第一个if的else子句结束

按照严格的缩进,嵌套的 if 应该再向内缩进一层。但按照约定俗成的习惯,我们将其写成 else if 并保持与顶层 if 对齐,使其在视觉上像一个多路分支。当Python的设计者创建语言时,他认为这是一个很好的惯例,于是直接将 elif 作为语言的一部分,而不是一种惯用写法。


函数定义:传值调用

最后,我们来看看函数的定义。在C语言中定义函数相对直接。

以下是Python和C语言中函数定义的对比:

# Python
def addtwo(a, b):
    c = a + b
    return c
// C
int addtwo(int a, int b) {
    int c;
    c = a + b;
    return c;
}

C语言中没有 def 关键字。函数定义包括:

  1. 返回值类型(int
  2. 函数名(addtwo
  3. 参数列表,必须声明每个参数的类型int a, int b
  4. 函数体,用花括号 {} 括起来。

在Python中,你不需要声明参数类型,因为Python是动态类型语言,变量的类型与其值绑定。而在C语言中,你必须明确告诉编译器参数 ab 是整数。如果调用时传入 6.0(浮点数),C语言不会自动进行类型转换,可能导致错误或不可预期的行为,编译器可能会给出警告,但不会替你修复。

return 语句在两种语言中功能基本相同。Python的 return 借鉴了C语言。

关于变量作用域:在C函数内部声明的变量(如 c),其作用域仅限于该函数内部。这与Python类似,在函数内部定义的变量在函数外部不可见。我们将在后面的章节中学习关于外部变量、静态变量等更复杂的作用域规则。


总结与展望 🎯

本节课中我们一起学习了多个核心概念,完成了从Python到C的“罗塞塔石碑”式对照学习:

  • 输入/输出:对比了 input/printscanf/printf,以及更安全的 fgets
  • 循环:学习了确定循环(for)和非确定循环(while)在两种语言中的实现,包括文件读取循环。
  • 文件操作:了解了如何使用 fopenfgetsfclose 在C语言中读取文件。
  • 字符串:认识到C语言中的字符串本质上是字符数组,需要谨慎管理内存。
  • 条件判断:深入理解了 if-else 逻辑,并辨析了C语言中 else if 惯用法与Python中 elif 关键字的本质区别。
  • 函数:学习了如何定义函数,重点是C语言中必须明确指定参数和返回值的类型。

我们讨论了整数和浮点数。在接下来的第五、六章,我们将深入更复杂的内容。本课程的一个重要目标是探索:如何利用C语言的基础构件(如结构体、指针、字符数组)来实现Python中的高级对象(如字符串、列表、字典)。我们不仅学习如何用C语言编程,更要理解像Python、JavaScript这样的高级语言底层是如何构建的。在课程结束前,我们将回到这个主题,窥探用C语言构建Python核心数据结构的思想。

这是一门内容丰富的课程,本节信息量也很大。请务必花时间消化这些代码示例,每一行都旨在教授特定的知识。不要急于求成,只有理解了背后的原理,完成作业才有意义。希望你能扎实地掌握这些材料。

加油!🚀

007:历史背景与教程导论 🎓

在本章中,我们将学习C语言的历史背景、本课程的学习路径、教材结构概览,并初步了解C语言的核心特性及其潜在风险。

大家好,欢迎来到卡内基和里奇(指《C程序设计语言》作者)著作的第一章。我是查尔斯·塞弗伦斯,也是这门关于历史的课程的教授。欢迎来到这门课程,它实际上是一个学习路径的一部分。我认为C语言不应该是你的第一门编程语言,也不应该是最后一门。我有一系列免费的在线课程,分布在freeCodeCamp、Coursera、edX等平台。在你当前的学习路径中,你正处于C语言编程阶段。我们学习C语言编程,并非仅仅为了掌握C语言本身,而是为了从历史角度审视计算机的工作原理,并为进一步学习计算机体系结构打下基础。我的目标不是教授如何用C语言编码,而是会借助C语言来解释计算机以及像Java这样的语言是如何工作的。这为我提供了一种向你解释Java的方式。

教材大纲是一本相当典型的计算机科学教科书结构,它从简单开始,然后内容会变得相当深入。第1章到第4章(我们现在就在第1章)主要是语法,集中在编程语言本身。特别是如果你了解一点Java、Python或JavaScript,其中一些语法会让你觉得似曾相识。答案是,这是因为所有这些语言都源自C语言。所以它感觉上就像另一门编程语言。

然而,数组不是列表,字符数组也不是字符串。字符数组看起来像字符串,但它们的运作方式不像字符串。你可能会因此陷入各种麻烦。但除此之外,一旦你不再担心事物的长度,假装一切正常(当然,这在写代码时是危险的),第1章到第4章的感觉就很像你在学习任何其他编程语言。

但第5章和第6章是本书最有价值的章节,它们也会变得困难得多。所以不要轻视第1到第4章,因为第5章和第6章会快速深入。第7章和第8章则主要是补充细节,不那么关键,只是填补了所有空白。这就是本书的大纲。只需预期第1到第4章会比较平顺,而第5章和第6章会让我们真正进入核心领域。

现在,让我们具体看看第1章的内容。第1.1到1.5节看起来与你学过的其他编程语言没有太大不同。

以下是第1章后续小节的核心内容介绍:

第1.6节讨论静态分配数组。在声明数组时,你必须知道它的大小,并且直到第5章我们讨论动态内存和指针之前,你都无法调整其大小。

第1.7和1.8节涉及函数和参数。在这个早期阶段,所有调用都是传值调用传引用调用在第5章才会介绍,因为我们需要先了解指针。尽管在第1章中偶尔会使用一些指针语法,但深入讨论要等到第5章。

第1.9节是关于字符数组。请仔细阅读这一节,因为C语言中没有字符串对象,实际上C语言中根本没有对象。

第1.10节讨论函数间的变量作用域。这部分感觉与其他语言类似,部分原因在于其他语言从C语言中获得了灵感。


🎼 现在,让我们快速看一下C语言的字符数组。我们必须理解,字符数组的大小在分配时就确定了,并且没有自动扩展功能。如果你写了一个循环,超出了数组的边界——例如,我有一个长度为10的字符数组,却写了一个循环,向其中存储数据直到索引1000——最终程序会崩溃。在Python中,你可以直接添加字符,而在C语言中,如果你添加的字符超出了分配的空间,系统就会崩溃。

你可能听我说过不止一次,C语言可能要为计算领域90%的重大安全漏洞负责。这种分配一个数组然后肆意越界访问的代码,最终导致人们可以向操作系统、路由器等各种系统中注入恶意内容。

这就是为什么我们不常使用C语言编写程序。我的意思是,我们在这里,在第一章的第一页示例中,就看到了为什么我们不经常写C语言程序,或者即使写,也必须非常仔细地审查代码。它确实非常快,但也非常危险。


在本节课中,我们一起学习了C语言课程的历史背景和导论部分。我们了解了本课程在学习路径中的位置、教材各章节的难度分布,并初步认识了C语言中数组、函数、字符数组等核心概念的特性与潜在风险,特别是内存管理和安全性方面的问题。这为我们后续深入学习C语言及其底层原理奠定了重要的基础。

008:字符串与字符详解 🧵

在本节课中,我们将深入探讨C语言中字符串与字符的核心概念,理解它们与Python等高级语言在处理方式上的根本区别。我们将学习字符常量、字符串在内存中的表示、字符集以及如何操作字符串。


字符串常量与字符常量

在大多数编程语言中,字符串和字符的处理方式略有不同。例如,PHP、Python和JavaScript将单引号和双引号视为大致相同,它们都用于创建字符串常量。字符串是一个具有长度的多字符序列。

然而,C语言没有内置的“字符串”类型。C语言中的字符串实际上是一个字符数组,并且这个数组的末尾有一个特殊的零字符(\0)作为结束标记。

在C语言中,单引号用于表示单个字符,而双引号用于表示字符数组。因此,一个包含一个字符的双引号字符串(如 "A")实际上占用两个字节:一个字节是字符本身,另一个字节是字符串结束符 \0

相比之下,在Python中,字符串对象本身知道自己的长度,并不需要一个特殊的结束字符。


C语言中的字符:本质是整数

在C语言中,一个字符(char)本质上是一个字节(byte),它是一个短整数。在大多数计算机上,一个字节通常是8位。

因此,你必须非常小心地区分双引号(字符串)和单引号(字符)的用法。在C语言中,单引号表示的字符更像整数,而不像字符串。在Python中,单引号和双引号可以互换使用,但在C语言中不行。

公式/代码表示:

char single_char = 'A'; // 这是一个字符,本质是整数(如65)
char string_array[] = "A"; // 这是一个字符数组,包含 'A' 和 '\0'


字符集与编码

C语言中的 char 类型就像一个数字,它是一个很小的数字,长度为8位。因此,它的值范围是0到255。字符的具体表示取决于所使用的字符集,最常见的是ASCII码。

你可以通过查看ASCII码表来找出字母‘A’对应的数字表示。在Python中,我们可以使用 ord() 函数来查看单个字符的序数值(ordinal position)。

代码示例:

# Python 示例
print(ord('A'))  # 输出:65

Python 3 使用多字节字符来表示Unicode,Unicode是一个比8位大得多的字符集(通常是32位)。UTF-8是一种表示Unicode的编码方式。例如,笑脸符号😊在Unicode中对应一个很大的整数(如128522)。

而标准的C语言(不添加额外库)通常无法直接表示像笑脸这样的Unicode字符,它主要处理ASCII字符集。

在C语言中,字符常量实际上就是ASCII字符集中的整数常量。打印字符时,使用 %c 格式符会输出字符本身,而使用 %d 格式符则会输出其对应的整数值。

代码示例:

// C 语言示例
char c = 'A';
printf("作为字符: %c\n", c); // 输出:A
printf("作为整数: %d\n", c); // 输出:65

C语言中的字符串:字符数组

C语言中的字符串并不是真正的“字符串”类型,它们是字符数组,并且没有存储长度信息。

在Python中,你可以直接询问一个字符串的长度(len(string)),字符串对象知道自己的长度。但在C语言中,要获取字符串的长度,需要编写一个循环来扫描字符数组,直到找到结束符 \0(即整数0)为止。

字符串的结束符是一个特殊字符,写作 '\0',其整数值就是0。一个字符串由一系列非零字符组成,最后以一个零字符结束。

字符串的长度是指从数组开头到结束符 \0 之前的字符数量。这不同于数组本身分配的空间大小。

核心概念:

  • 字符串必须以 \0 结尾。
  • 操作字符串(如追加)时,必须确保数组有足够的空间,并且要维护好结束符 \0 的位置。
  • 如果没有正确终止字符串,程序可能会继续读取数组之后的内存,导致不可预测的行为或崩溃。

计算C语言字符串长度的函数(如 strlen 来自 string.h)内部就是通过循环查找 \0 来实现的,这与Python中直接获取对象属性的 len() 函数有本质区别。


实践练习:原地反转字符串

为了巩固对C语言字符串(字符数组)的理解,课程安排了一个经典的练习:原地反转一个字符串

这是习题1.17。你需要编写一个函数,在不使用额外字符串空间的情况下,反转一个给定的字符数组(字符串)。你必须处理各种情况:偶数长度字符串、奇数长度字符串、空字符串和单字符字符串。

要求与建议:

  • 不要作弊:虽然互联网上可能有无数解决方案,甚至AI也能给出答案,但请务必自己动手完成。
  • 动手画图:在编码前,尝试在纸上画出字符数组和指针/索引的变化过程,这对理清逻辑至关重要。
  • 这是一道经典的面试题:完成这个练习能让你深刻理解底层存储(带结束标记的字符数组)是如何工作的,并为此感到自豪。
  • 享受过程:认真对待这个作业,它代码量并不大,但思考的过程非常有价值。

总结

本节课我们一起学习了C语言中字符串与字符的核心知识:

  1. 字符(char 本质上是小整数,通常基于ASCII码。
  2. 字符串 在C语言中是\0 结尾的字符数组,而非独立类型。
  3. 字符串长度 需要通过遍历数组查找 \0 来计算。
  4. 操作字符串 时必须时刻注意数组边界和结束符的正确维护。
  5. 通过原地反转字符串的练习,可以深入实践这些概念,理解底层内存操作。

理解这些基础是掌握C语言的关键,它们与Python等高级语言的处理方式有显著不同,凸显了C语言接近硬件的特性。

009:第2章 类型、运算符与表达式的历史背景 - 第1部分

欢迎来到第2章:类型、运算符与表达式。我不会复述书中的所有内容,我建议你阅读教材,它写得很好。我将重点讲解一些内容,如果你来自Python、JavaScript或PHP等语言,可能会觉得这些内容有些奇怪,因为在那些语言中,事物都是对象,你可能一直在使用对象而不自知。

我们将讨论数据类型和存储分配。教授C语言历史视角的一个好处是,我们必须讨论存储分配。floatdouble类型处理得相当好,部分原因是在C语言早期,它们完全由软件实现,因此设计得简单且有效。当时并不追求极致的速度,真正追求高速的是整数和字节(字符)数据类型、类型转换等。这里有一个故事,它连接了Python2中的整数除法、Python3中除法运算的改变及其带来的困扰,以及这种改变的原因和实现方式。这再次与性能以及当时做出的简单(有时令人遗憾)决定有关。

位逻辑运算。我们会讨论它们。你可能不会经常使用,但从历史背景理解它们为何如此完备非常重要。这主要是因为计算机从“字”导向转向“字符”导向时,我们这些程序员都在用“字”思考。如果没有移位、掩码和位运算,我们会觉得无法编程,因为在字导向计算机中,我们的很多工作就是掩码和移位。所以,我们必须拥有这些功能,尽管实际使用可能没有预想中那么多。

那么,让我们从除法开始。

在早期,我们不太担心做除法。如果你在做除法并且关心结果,很可能是在进行浮点运算,因为那是在做科学计算,而且是在超级计算机上进行的。通用计算机上不常做这个。Unix系统是为通用计算机设计的。在通用计算机上,人们会想:整数除法有那么重要吗?我确信他们当时做出了某个决定。我不知道具体原因,可能与他们当时使用的某台计算机有关,那台计算机的硬件执行截断除法,而非截断除法(或舍入除法)则由软件实现。或者,很多计算机甚至没有快速的浮点运算单元。他们当时使用的一些计算机,所有浮点运算都由软件完成,甚至整数除法也可能通过循环等软件方式实现。我们不得而知,但他们的决定是:整数除法执行截断

这正是从Python 2迁移到Python 3过程中最痛苦的事情之一。

Python 2已有超过25年历史,它是在C语言之后不久用C语言编写的。从Python 2到Python 3的过渡是一件大事,花了很长时间,大约12年才完成。Python 2曾经非常棒,但存在一些问题。由于Python 2与C语言关系密切,其原生字符串是ASCII,而非Unicode,这意味着它甚至无法处理西班牙语字符,更不用说亚洲字符了。

程序员在迁移过程中得到了很多帮助,比如自动代码转换器、语法检查器等。他们做了很多工作,例如将某些库(如print函数)先引入Python 3,然后反向移植到Python 2,以便你能从使用print语句过渡到print函数。有很多措施使这个过渡尽可能容易。但有一件事他们始终无法完美解决,我们只能硬着头皮适应,那就是:在Python 2中,整数除法返回整数

并且除法是截断的。所以,在Python 2中,3 / 4的结果是0;而在Python 3中,整数3除以整数4得到浮点数0.75,因为计算器就是这样做的。Python 2的除法之所以截断,是因为在80年代这似乎不太重要,而C语言的整数除法就是截断的。

所以,在C语言中,四分之三(整数运算)等于零,Python也是如此。20多年后,这成了我们无法自动转换到Python 3的一件事。Python 3按照Python自己的新方式处理。

这个问题在C语言中不那么严重,因为C实际上是强类型语言。这意味着如果我想计算3 / 4,我知道操作数是整数还是浮点数,我可以强制进行类型转换。但在Python中,你只是写一个变量,它会根据表达式的结果推断变量类型。

而C语言必须将x声明为floatdoubleint。因此,当C程序员写除法时,他们需要知道必须对值进行类型转换,或者使用浮点常量来触发表达式中的类型转换。所以,当你在阅读本章,看到这些类型转换和强制转换的内容时,那正是为了解决这类问题。后来Python简化了它,却又使其变得有些棘手,然后不得不修复它。Python 3中的处理方式更好,你们大多数人都只学过Python 3,所以很幸运。

是的。

是的。


本节总结

在本节中,我们探讨了C语言第2章中关于类型、运算符和表达式的历史背景,特别是整数除法的截断行为。我们了解到,这一设计选择源于早期计算机的硬件限制和性能考量,并对后来如Python等语言产生了深远影响。理解这些历史背景有助于我们更好地把握C语言的设计哲学,并明白为何在某些情况下需要进行显式的类型转换。下一节,我们将继续深入探讨其他数据类型和运算符。

C语言编程:2.1.4:进制与数据表示的历史背景(第二部分)

在本节中,我们将探讨计算机中数据表示的基础——进制系统。我们将了解为何在编程中,除了常用的十进制,还需要理解二进制、八进制和十六进制,并学习它们之间的转换方法。

上一节我们介绍了数据类型和存储的基本概念。本节中,我们来看看计算机如何用不同的进制系统来表示和打印数据。

当我们开始以比特为单位思考存储分配时,就需要知道如何表示和打印数据。这不仅仅限于十进制。实际上,八进制和十六进制在打印二进制原始数据方面更具优势。十进制是我们日常生活中使用的数字系统,例如“你想要多少披萨?我想要16个或22个”。这是我们人类自然的思考方式。

要讨论进制,让我们从回顾十进制开始。十进制中有个位、十位,当然还有百位、千位等。

在数字42中,十位上的4代表4个10,可以看作是4乘以10,即40。个位上的2代表2个1。所以42就是40加2。这是我们本能的理解方式。

现在让我们看看八进制。十进制中的42,在八进制中是52。八进制的含义是,不同位置上的数字代表不同的值。在数字52中,5代表5个8,2代表2个1。因此,5乘以8等于40,加上2乘以1等于2。将八进制的52转换为十进制,就得到了42。八进制与二进制完美对应,因为三个二进制位正好对应一个八进制位。

早期我经常使用八进制,但现在我们更倾向于使用十六进制,因为它表示更紧凑。在十六进制中,最右边的位仍然是个位,下一位是16位。因此,在数字2A中,2在16位的位置上,代表2乘以16,即32。剩下的部分是10,我们用字母A表示。10加32等于42。

问题在于,我们只有0到9这十个数字符号。因此,按照惯例,10用A表示,11用B,12用C,13用D,14用E,15用F。F代表四个二进制位全为1。这使得十六进制和二进制之间的转换非常快捷。如果需要查看内存转储,我可以将其以十六进制形式转储,然后在需要时快速转换为二进制。

在十六进制和二进制之间来回转换是一个技巧。我并不要求你精通此道,但你可以获取示例代码并尝试。以下是一个将十进制数(如1234)转换为八进制和十六进制的示例。

这个转换算法本质上是从数字的最低位(最右边)开始,逐步向高位处理。具体方法是使用取模运算。

以下是转换算法的步骤:

  1. 取数字1234,计算 1234 % 8,余数为2。这是新数字的最右边一位。
  2. 使用整数除法 1234 / 8 截断余数,得到154。
  3. 将余数2累积到转换后的数字中。
  4. 计算 154 % 8,得到2,这是从右数第二位。
  5. 计算 154 / 8,得到19。
  6. 计算 19 % 8,得到3,这是从右数第三位。
  7. 计算 19 / 8,得到2,由于2小于8,这成为从右数第四位。
  8. 因此,十进制1234在八进制中表示为2322。

对于十六进制转换,过程完全相同,区别在于需要处理A到F的字符。我创建了一个包含这些字符的字符串。这里我们以Python为例。

我们重复执行取模16和整数除以16的操作。对于数字1234,转换为十六进制后,从低位开始得到2、D、4,我们读作4D2。

这就是一个在不同进制间转换的算法。我们倾向于使用这种取模运算的方法。在本课程中,进制转换并非重点,我们不会花大量时间在这上面。但我们只需要意识到,由于过去需要高度关注比特的存储方式,我们常常以十六进制或八进制的形式打印数据。

因此,我希望你了解这些概念。例如,查看我们已经见过的ASCII码表,你会看到字母A在十进制中是65,在十六进制中是41,在八进制中是101,在二进制中是一串0和一个1。

这仅仅是让你知道,在过去,你必须更加了解计算机内部的真实比特状态,而十六进制和八进制是了解这些比特状态的更好方式。

本节课中,我们一起学习了不同进制系统(十进制、八进制、十六进制)在计算机数据表示中的作用,理解了它们与二进制的对应关系,并掌握了通过取模和整数除法进行进制转换的基本算法。了解这些历史背景和表示方法,有助于你更深入地理解计算机底层的数据存储与处理。

011:类型、运算符与表达式的历史背景(第3部分)🎯

在本节课中,我们将了解C语言诞生时的历史背景,特别是它如何处理字符、字节与字,以及为何需要位掩码和移位操作。我们还将简要探讨字节序(大端序与小端序)的概念。


上一节我们讨论了早期计算机的数值表示。本节中,我们来看看C语言在处理字符数据方面的创新,以及它如何从面向“字”的编程过渡到面向“字节”的编程。

C语言是早期创新语言之一,其重要特性之一是支持字节寻址计算机。如今我们视字符串为理所当然,可以轻松访问其中的字符。但在C语言及其催生的那代计算机出现之前,情况并非如此。

当时硬件无法直接加载单个字符,只能加载整个。程序员必须从字中手动找出所需的字符。

C语言的直接前身是B语言。B语言是一种很酷的、面向字的低级语言。C语言则从B语言演变而来,成为了一种面向字节的语言。C语言决定采用字节寻址。

以下是我在1975-1976年使用的CDC 6500计算机上处理字符支持的方式。它是一台科学计算机,几乎不关心字符打印,甚至不支持小写字母。它使用60位的字,并将6位的大写字符打包进这些字中,用一系列零来填充剩余空间。

如果我想将“Hello World”存入CDC 6500,它需要两个字。第一个字打包了“Hello W”,第二个字包含了“orld”和用于填充的零。如果我想要知道这个两字字符串中的第五个字符(例如字母‘O’),就必须手动操作。

以下是提取特定字符所需的步骤:

  1. 创建掩码:创建一个位模式,在想要保留数据的位置置1,在想要清除的位置置0。
  2. 应用掩码:将“Hello World”数据与掩码进行按位与运算,以提取目标字符所在的位。
  3. 移位操作:将结果右移适当的位数,使目标字符位于字的底部(最低有效位)。

最终,我才能写一个if语句来检查第五个字符是否是字母‘O’。你可以想象,当我开始使用允许数组语法(如string[5])来访问字符的编程语言时,我是多么高兴。在1977年,将字符视为数组的概念对我们来说还很陌生。

因此,整整一代程序员在其整个职业生涯中都不必进行任何掩码和移位操作。本章将向你介绍这些概念。你可能会问,既然C语言能自动处理这些,为什么还要展示底层操作?答案是,如果C语言没有提供良好的掩码和移位操作支持,像我这样的程序员就不会尊重这门语言,因为我们当时一直在面向字的计算机和语言中做这些事情。


就在C语言和Unix系统让字符处理变得安全便捷时,我们遇到了另一个问题:字节序

如果你要加载一个字,你是先加载最低有效位(小端序)还是最高有效位(大端序)?大多数计算机是大端序,这对我们软件开发人员来说最合乎逻辑,因为我们认为数据就应该那样布局。

但一些处理器(如当时的Intel)为了性能,选择成为小端序。这样,它们在执行加法运算时,可以先加载整数的低端部分并开始计算,同时加载高端部分,从而实现加载和加法运算的重叠。自那以后,我们就一直受困于许多小端序微处理器。大端序与小端序是计算机科学中较难解决的问题之一。

我接下来展示一些代码,并不是要你完全理解,而是希望你体会一下我们当年不得不处理字节序问题时的感受。让我概述一下这段代码在做什么,但不会详细讲解,它看起来有点吓人。在学到第5章之前,你可能无法理解大部分代码。这里我们只是简单讨论一下,如果没有字符数组,掩码和移位操作是如何工作的。

在这段程序中,我首先创建了一个字符数组:

char s[] = "Hello World";

这个字符数组的长度是“Hello World”加上一个终止符‘\0’,即11+1=12个字符。

接下来这行代码int *si = (int *) s;的作用是:我实际上是想将同一块存储空间视为一个整数数组。这行代码获取第一个字符的地址,并将其从字符指针转换为整数指针。这里我提前涉及了第5章指针的概念,所以不期望你现在完全理解,只是让你有所了解。

在前两行中,我有了一个字符数组和一个(指向同一内存的)整数数组。这是一个32位整数,意味着字符是以小端序的方式被打包进32位整数中的。

因此,如果你从左到右查看内存,在第一个32位整数(即前四个字符)中,最先出来的字符是‘l’。你可以看到小端序的效果,这在你脑海中可能觉得应该是移位后的结果,但这是因为程序运行在小端序计算机上。不同的计算机会给出不同的结果。

通过掩码和移位,我将尝试提取出‘e’(它通常是字符串的第二个字符,但在第一个整数中,它位于从底部数起的第二个位置)。以下是操作步骤:

  1. 我创建一个掩码0xFF(即8个比特位全为1)。
  2. 我将这个掩码左移8位。
  3. 我将整数数据与移位后的掩码进行按位与运算,以提取出包含‘e’的8位。
  4. 由于提取出的字符位处于错误的位置,我必须将掩码后的结果再右移8位,使其位于底部。这样我才能检查那个字母是什么。

这就是我提取字符串第二个字符以检查它是否是‘e’的方法。因为在那个时代,我不能像在Python中那样直接比较字符串的第二个字符。这就是为什么后来人们构建字符串类,而不是直接使用字符数组来处理这类问题。我让情况变得更复杂,因为我从字符数组开始,将其视为整数数组,然后操作这些整数。

你不需要完全理解这段代码。只需庆幸你使用的是Python,或者即使用C语言,无论机器是大端序还是小端序,你都可以将字符数组视为字符数组,并使用方括号符号(如s[2])来获取第三个或第五个字符。


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

  • 数字进制转换。
  • 整数除法的历史背景(如Python 2的行为)。
  • 整数、字和字节的概念。
  • 掩码和移位操作在早期字符处理中的必要性。
  • 字节序(大端序与小端序)问题。

这些主题在本章中都有涉及,它们可能让你感到陌生和不自然。但请尝试阅读并理解它们,以后当我们学习结构体、指针、地址运算等内容时,这一切会变得更有意义。

012:姜饼人赛道办公时间 🏁

在本节中,我们将跟随查克教授在密歇根州南黑文德国赛道的即兴办公时间,聆听一位学生的编程学习经历与应用实践。我们将了解他从学习Python到应用Django的旅程,并从中获得对编程学习的启示。

大家好,我是查克。我们又一次在南黑文的德国赛道进行即兴办公时间。

我遇到了一位学生,他刚好路过并打招呼说:“嘿,我正在学习《给所有人的Python》课程。”于是我说:“嘿,我们来录个视频吧。”所以,请介绍一下你自己,告诉我们你的名字(只提名字),并谈谈你学习Python的经历。

我的名字是克里斯。我从事IT项目管理大约15年了,想更深入地了解技术方面,所以我学习了《给所有人的Python》课程。我非常喜欢这门课,学到了很多,现在正在学习查克博士的Django课程。我也很喜欢这门课,它真的很有趣。我鼓励任何对计算机编程感兴趣的人投身其中,亲自动手实践。查克博士是引领你入门的最佳人选。

那么,请告诉我你在工作中是如何应用Python技能的。

我找到了一份实验室自动化相关的工作。我们涉及试管存储和分拣。我构建了一个应用程序,它位于试管存储区和分拣器之间。我可以获取数据,并重新排列试管,将它们以所需的新格式放入架子中。我使用Python来实现这一切。

我很好奇你对《给所有人的Django》课程有什么期望,因为我开设这门课有一个非常具体的原因。我想知道我为该课程设定的目标是否达到了效果。

我想向互联网开发方向发展,而我的基础是Python,所以这确实是我选择这条路线的主要动机。

对我来说,我开设这门课的目的并不完全是关于构建Web应用程序。

但你谈论的内容确实是在构建Web应用程序。

是的,没错。对我来说,我希望人们学习创业精神相关的编程。我希望人们学习……更好的Python,以更多样的方式应用Python。但显然,你最终会构建Web应用,这真的很棒。

是的,这会很有趣。Web应用部署起来更容易,你不需要去客户现场实施,只需在一个地方修改,然后任何访问它的人都能看到更新。所以你可能会为一些本地的小型需求开发Web应用,但基本上你不是在做一个Twitter的克隆品,你只是在做一些辅助工作的工具。

是的,用于我们内部的实用工具。

好的,这非常酷。非常感谢。你有什么想对你的同学们说的吗?

是的,慢慢来,完成所有练习,不要跳过任何内容,确保在继续前进之前真正掌握了所学知识。

我完全同意这一点。学习关乎精通,而非速度。如果你匆忙完成却什么都没记住,那完全是浪费时间。

好了,我们在密歇根州南黑文的德国赛道向大家问好。我们俩大约10到15分钟后都要回到赛道上。祝大家一切顺利,干杯!


本节课总结

在本节课中,我们一起聆听了学生克里斯分享的编程学习路径。他从IT项目管理背景出发,通过学习《给所有人的Python》课程打下基础,并进一步学习Django以构建实用的Web应用程序来解决工作中的自动化问题。他的经历强调了动手实践和扎实掌握基础知识的重要性,而非追求学习速度。这为初学者提供了一个从兴趣到实际应用的真实参考范例。

013:控制流的历史背景 🧭

在本节课中,我们将学习C语言第3章“控制流”中一些独特的历史背景和语法细节。我们将探讨分号的使用、else if的微妙之处、switch语句的起源、逗号运算符,以及早期编程中追求代码极度简洁的倾向。理解这些背景知识,能帮助我们更好地阅读教材中的代码,并明白现代编程风格与过去的差异。


分号:终结符与分隔符 🔚

上一节我们介绍了本章的概述,本节中我们来看看分号在不同语言中的角色。分号在C语言中是一个语句终结符,这意味着每个独立的语句都必须以分号结束。

C语言示例:

x = x + 1;
x = x / 2;
printf("%d", x);

然而,在其他语言中,分号的规则有所不同。以下是不同语言中分号作用的对比:

  • Python:分号是分隔符而非终结符,通常可以省略。它允许你将多个语句写在同一行。
    x = x + 1; x = x / 2  # 合法但不常见
    
  • Java:遵循C语言模式,分号作为终结符
    x = x + 1;
    System.out.println(x);
    
  • PHP:紧密遵循C语言,分号作为终结符
  • JavaScript:分号作为分隔符,在大多数情况下可以省略,但许多开发者习惯加上。
  • Shell脚本:使用分号作为命令分隔符,这与在同一行写多个C语句的样式相似。

理解分号是“终结”还是“分隔”语句,是阅读不同语言代码的关键。


else if:语法嵌套的错觉 🧩

了解了分号的基本用法后,我们来看一个更微妙的语法点:else if。在C语言中,else if实际上是两个独立的关键字:elseif。它的结构本质上是嵌套的。

C语言中的else if结构:

if (expression1) {
    // 语句块1
} else if (expression2) {
    // 语句块2
} else {
    // 语句块3
}

从技术上讲,上述代码等同于以下完全展开的嵌套形式:

if (expression1) {
    // 语句块1
} else {
    if (expression2) {
        // 语句块2
    } else {
        // 语句块3
    }
}

而在Python中,elif是一个独立的、专门的语言构造,并非elseif的简单组合。这使得Python的语法在表达多分支条件时更加扁平化和优雅。

Python中的elif结构:

if expression1:
    # 语句块1
elif expression2:  # `elif`是一个关键字
    # 语句块2
else:
    # 语句块3

这种差异体现了语言设计上的不同思路。虽然C程序员很少会像上面那样刻意缩进,但了解其底层逻辑有助于理解代码结构。


switch语句:跳转表的遗产 🗺️

上一节我们讨论了条件分支,本节中我们来看看另一种分支结构:switch语句。switch语句的引入有着特定的历史背景。在早期的汇编语言编程中,程序员经常使用“跳转表”来根据一个整数值快速跳转到不同的代码段。

C语言中的switch语句就是对这种底层模式的一种高级抽象,它比当时Fortran语言中的COMPUTED GO TO语句要清晰和优雅得多。

C语言switch语句示例:

switch (value) {
    case 1:
        // 执行操作1
        break;
    case 2:
        // 执行操作2
        break;
    default:
        // 默认操作
}

switch语句允许你将多个case标签堆叠在一起,并且需要一个break语句来防止“贯穿”到下一个case。尽管在现代编程中,由于编译器优化非常出色,一连串的if...else if语句通常和switch语句效率相当,因此switch的使用频率已大大降低,但了解其历史渊源仍然很有意义。


逗号运算符:循环中的轻量级分隔符 ⚖️

在C语言中,逗号,可以作为一个运算符或分隔符使用。它最常见于for循环的初始化和增量部分,用于在同一个语法位置执行多个表达式。

逗号在for循环中的应用:

for (i = 0, j = strlen(s) - 1; i < j; i++, j--) {
    // 循环体:使用i和j进行操作
}

在上面的代码中:

  • i = 0, j = strlen(s) - 1:在循环开始前,同时初始化ij
  • i++, j--:在每轮循环结束后,同时递增i和递减j

你可以把逗号理解为for循环结构内部的“轻量级分号”。因为for语句的括号内已经用分号分隔了三个部分(初始化、条件、增量),所以当需要在其中一个部分做多件事时,就使用逗号来分隔。这是一种简洁的惯用写法。


追求简洁:历史背景下的代码风格 ⚡

早期C语言程序员很多有汇编语言背景。在那个时代,编译器优化技术还不成熟,硬件资源也极其有限。因此,程序员常常手动编写极其简洁(有时甚至晦涩)的代码,以期获得更高的执行效率。

以下是教材中可能见到的一些“简洁”风格:

  1. 在条件中完成工作:将读取、计算和赋值全部塞进循环的测试条件中。
    while ((c = getchar()) != EOF && c != ‘ ‘ && c != ‘\n’ && c != ‘\t’) {
        // 循环体可能为空,因为所有工作都在while的条件中完成了
    }
    
  2. 利用表达式副作用:一个表达式既完成计算,又用于条件判断。
  3. 极简的循环体:如上面例子所示,有时循环体只是一个分号;,因为所有操作都在循环头部的测试或增量部分完成了。

这种风格源于对生成高效机器码的直接关注。当时程序员甚至会检查编译器输出的汇编代码,并尝试用手写更简洁的C代码来“引导”编译器生成更好的机器码。

重要提示:对于现代初学者来说,不应刻意模仿这种晦涩的编码风格。当今的编译器优化能力已经非常强大,清晰可读的代码远比看似“聪明”的简洁代码更重要。我们在阅读教材时,理解这些代码背后的历史原因即可,但在自己编写代码时,应优先保证逻辑清晰。


总结 📚

本节课中我们一起学习了C语言第3章涉及的一些历史背景和语法细节。

  • 我们分析了分号在C语言(作为终结符)与其他语言(如Python,作为分隔符)中的不同角色。
  • 我们揭示了C语言中else if 实质是嵌套语法,与Python的独立关键字elif的区别。
  • 我们探讨了switch语句起源于汇编语言的“跳转表”,并了解了其基本结构。
  • 我们介绍了逗号运算符for循环中用于分隔多个表达式的惯用法。
  • 最后,我们理解了早期C代码追求极度简洁风格的历史原因,并强调在现代编程中代码可读性优于这种过时的“优化”。

掌握这些背景知识,能帮助你更顺畅地阅读经典教材中的示例代码,并培养出编写清晰、易维护的现代代码的好习惯。

014:函数与程序结构的历史背景 - 第1部分 🧱

在本节课中,我们将开始更深入地探讨C语言的核心机制。课程的目标之一是让大家理解程序在底层是如何工作的。最终,在后续课程中,我们甚至会深入到硬件、架构和逻辑门层面。现在是时候揭开面纱,看看事物运作的原理了。本章我们将学习多个重要概念,其中最关键的是的概念。我们还将了解按值传递按引用传递的工作原理,初步接触递归,以及预处理器。这些内容不仅关乎函数如何工作,更关乎函数是如何被实现的,以及这种实现方式如何影响它们的行为。

首先,我们来讨论一个非常巧妙的计算机科学概念:

什么是栈? 📚

栈是一种我们使用的数据结构,它有几个关键属性。栈的概念是:我们从一个空栈开始,将东西放入栈中,然后再取出。我们取出的顺序是后进先出。你可以将元素压入栈顶,也可以从栈顶弹出元素。

我们可以用Python列表来模拟栈的行为:

stack = []          # 创建一个空栈
stack.append('1')   # 栈中现在有 ['1']
stack.append('2')   # 栈中现在有 ['1', '2']
stack.append('3')   # 栈中现在有 ['1', '2', '3']
item = stack.pop()  # 弹出 '3',栈变回 ['1', '2']

栈底元素是‘1’,栈顶元素是‘3’。当我们执行pop()操作时,得到的是最近压入的元素‘3’,并将其从栈中移除。这被称为后进先出原则。我们将在函数调用中使用栈。

按值传递与栈帧 🖼️

历史上,当我们讨论按值传递按引用传递时,我们通常说:按值传递意味着主程序中的一个值(例如变量ma的值为42)会被复制到函数中,参数o获得的是42的一个副本,而不是原始的ma

这是一个巨大的简化。让我们看看栈是如何用来实现这一点的。

在C语言中,函数开始执行前在其内部分配的变量被称为自动变量。实际上,主函数main中的int ma = 42;也是一个自动变量,因为main本身就是一个函数。

当程序执行到int ma = 42;并打印ma时,C运行时环境会在栈上分配一个整数空间,并将42赋值给它。这就是在main中第一个打印语句时栈的状态。

接着,我们调用函数one并传入ma。此时,C运行时库会在函数one开始执行前,分配一个称为栈帧的结构。一个栈帧包含了函数的参数和其内部的自动变量。

在这个例子中,我们会得到两个变量:整数参数o和整数自动变量tn。在函数开始前,值42会从ma复制到o中。因此,栈帧就是该函数运行的上下文环境。

函数one开始执行时,o的值为42。此时栈上有两个42的副本:一个是main栈帧中的ma,另一个是one栈帧中的o。接着,执行o = o - tn;o的值变为32。但请注意,在one的栈帧之外,main栈帧中的42依然存在且未改变。在函数内部,我们只能看到当前栈帧(栈顶部分),看不到属于主程序的那部分栈。

函数打印出32,然后返回。此时,C运行时会移除one的栈帧,弹出栈上所有属于它的数据。程序流回到mainmain的栈帧中只有一个变量ma,其值仍是42。这是因为one只在自己的栈帧内操作,现在主程序回到了它自己的栈帧中运行。

main的视角看,one函数就像从未发生过一样。main有一些变量,one运行并创建了一个栈帧,main的部分数据被复制到one的栈帧中,one在其栈帧内操作,然后在返回的那一刻,栈帧消失。返回值也会放在栈帧中(图中未展示)。你可以看到,main开始时栈上有一个变量,one运行时栈增长,所有操作完成后栈又收缩回去,被改变的变量也随之消失。栈的升降过程,使得除了栈的临时变化外,仿佛什么都没发生。

Python与C的对比:按值还是按引用? 🔄

在Python中,我们也能观察到类似现象。例如,一个函数zap接收参数x,在函数内部改变了y(即传入的x的别名)的值并打印,但返回到主程序后,原始的x并未改变。这看起来很直观,似乎是按值传递,函数内部的改变不影响外部。

然而,其底层原理与简单的“按值传递”不同,更多是因为y实际上是一个指向对象的指针。当在zap内部执行y = “changed”时,y这个指针指向了另一个不同的字符串对象,但x这个指针本身从未改变。Python的运行时环境略有不同,但这导致了字符串变量在Python中表现得像是按值传递。

现在,让我们看一段相似但本质不同的C语言代码。在C中,main函数有一个字符数组x,其内容为“original”。然后调用zap函数并传入x。在zap函数内部,它接收一个字符数组参数y,开始时打印“original”,然后使用strcpy将“changed”复制到y中,最后打印“changed”。但当程序流回到main函数后,我们发现x的内容也变成了“changed”。

这是否意味着这是按引用传递呢?答案是:有点类似,但这有助于我们理解底层机制。

实际上,在C语言中,当你将一个数组传递给函数时,你传递的并不是数组的内容本身。因为数组可能非常庞大,所以C语言不会复制整个数组。当x被传递给zap并由y接收时,传递的实际上是字符串起始位置的内存地址,而不是字符串内容本身。这个地址(一个指针)被存储在zap的栈帧中。因此,xy都指向内存中同一个位置(字母‘o’的地址)。当我们调用strcpy时,我们是在覆盖那个内存地址上的字符内容,所以主程序中的x自然也会看到变化。

这并不完全是“按引用传递”,而是“按位置传递”。如果你错误地操作了这个位置(例如向只读内存写入),程序可能会崩溃。因此,当你在函数内操作传入的数组时,必须清楚自己在做什么。有时这是设计所需,有时则很危险。

寄存器变量 💾

另一个你会看到的历史概念是寄存器变量。这主要是为了说服那些熟练的汇编语言程序员,让他们相信用C语言也能获得与汇编语言相当的性能。

什么是寄存器?在中央处理单元中,数据通常存储在内存中,而寄存器则位于CPU内部。根据速度差异,访问寄存器可能比访问常规内存快数十倍。因此,如果你能将一个循环中频繁使用的变量(例如i)保存在寄存器中,速度会更快。

在C语言中,使用register int x;这样的声明,是在提示编译器:“接下来的几行代码中,x是一个非常重要的变量,我会频繁使用它。如果可能,请不要将其存储在内存中,尽量放在寄存器里。”寄存器变量一个奇怪的特点是:你无法获取寄存器变量的内存地址。

现代的编译器拥有近乎神奇的运行时优化器。即使是最简单的优化器也能显著提升代码速度。声明register实际上可能让优化器困惑。所以,register关键字的作用更像是告诉编译器:“我永远不会询问这个变量的地址,所以如果你觉得没必要,可以不把它放在内存里。”

尽管如今register关键字可能已不那么重要,但思考早期C开发者如何与底层运行时和汇编语言紧密联系,仍然是件有趣且引人入胜的事。


本节课总结
在本节课中,我们一起深入探讨了C语言函数机制的基石。我们学习了这一后进先出的数据结构,并了解了它如何通过栈帧来管理函数调用,实现局部变量的隔离和按值传递的复制效果。通过对比Python和C语言中数组参数传递的不同,我们明白了C语言中传递数组实际上是传递其首地址,这是一种“按位置传递”,使得函数内部能修改原始数组数据。最后,我们还简要回顾了寄存器变量这一历史概念,理解了其提升性能的初衷。这些底层概念为我们理解程序如何运行以及后续学习更复杂的数据结构和算法打下了坚实的基础。

015:递归与C预处理器 🧠

在本节课中,我们将学习两个核心概念:递归C预处理器。递归是函数调用自身的一种技术,而C预处理器则是在编译前对源代码进行处理的工具。我们将通过简单的例子来理解它们的工作原理和实际应用。


递归:函数调用自身 🔄

上一节我们介绍了函数的基本概念,本节中我们来看看一种特殊的函数调用方式——递归。

当一个函数调用自身时,这种行为被称为递归。递归是一个强大且优雅的概念。在处理树状结构(例如解析XML)时,递归是一种非常优雅的编码方式。

然而,初学者常看到的递归示例往往效率低下,并且可能误导你对递归用途的理解。本节的目标并非展示递归的最佳实践,而是通过一个简单(甚至有些刻意)的例子,来演示调用栈如何工作,以及递归如何与调用栈交互。

一个递归求和示例

以下是一个计算从1加到n的递归函数。例如,输入3,函数将计算1+2+3并返回6。当然,有更高效的方法(甚至代数公式)可以完成这个任务,但我们用此例来演示递归。

int sum_up(int above) {
    int below, sum, retval;
    below = above - 1;
    if (above <= 1) {
        return 1; // 递归的“基线条件”,用于停止递归
    }
    sum = sum_up(below); // 递归调用自身
    retval = above + sum;
    return retval;
}

递归与调用栈的工作原理

理解递归的关键在于理解调用栈。每次函数调用(包括递归调用)都会在内存中创建一个新的栈帧

以下是递归调用 sum_up(3) 时,栈帧变化的简化过程:

  1. main 调用 sum_up(3):创建第一个栈帧,其中 above = 3
  2. sum_up(3) 调用 sum_up(2):因为 above=3 不满足停止条件,它计算 below=2 并递归调用 sum_up(2)。此时,sum_up(3) 的栈帧被暂停,创建 sum_up(2) 的新栈帧。
  3. sum_up(2) 调用 sum_up(1):同理,创建 sum_up(1) 的新栈帧。
  4. sum_up(1) 触发基线条件above=1 满足 above <= 1,函数直接返回 1sum_up(1) 的栈帧被销毁,控制权返回给 sum_up(2)
  5. sum_up(2) 恢复执行:它收到 sum_up(1) 的返回值 sum=1,计算 retval = above(2) + sum(1) = 3,然后返回 3。其栈帧被销毁,控制权返回给 sum_up(3)
  6. sum_up(3) 恢复执行:它收到 sum_up(2) 的返回值 sum=3,计算 retval = above(3) + sum(3) = 6,然后返回 6main 函数。

核心要点

  • 递归的本质是利用调用栈。每次调用都创建一个包含参数和局部变量的新栈帧。
  • 必须有一个基线条件(如 if (above <= 1))来终止递归,否则会导致无限递归和栈溢出错误。
  • 从思维上,理解栈帧的创建和销毁,比单纯理解递归代码本身更容易把握递归的执行流程。

C预处理器:源代码的“预处理” 🔧

了解了程序运行时的结构后,我们来看看编译前的一个关键步骤——预处理。C预处理器在某种程度上独立于函数和程序结构,但它又是程序结构的重要组成部分。

C语言因其可移植性而强大,但硬件架构、操作系统和库函数会随着时间演变。例如,整型的位数(16位、32位、64位)、文件读取的库函数调用方式都可能不同。

预处理器提供了一种“打补丁”的方式,让你能根据不同的编译环境(如不同的操作系统、架构)生成稍有不同的C源代码,而无需手动修改核心逻辑代码。

预处理器的作用

预处理器不是编译器,它是一个源代码到源代码的转换器。它在编译器开始工作之前运行。

主要指令包括:

  • #include:包含头文件。
  • #define:定义宏或常量。
  • #ifdef, #ifndef, #endif:条件编译。

预处理器示例

1. 头文件包含 (#include)
当你写下 #include <stdio.h>,预处理器会找到这个文件,并将其内容原地展开,替换掉这行指令。你可以使用 gcc -E 命令只运行预处理器,查看展开后的代码。

2. 条件编译与宏替换
以下代码展示了如何让同一份源代码适应不同环境:

#define USE_LONG // 这行可以注释或取消注释来改变行为

#ifdef USE_LONG
    #define INT_32 long
#else
    #define INT_32 int
#endif

INT_32 ip_address; // 预处理器会根据USE_LONG的定义,将INT_32替换为long或int

在这个例子中,INT_32 是一个编译时常量字符串。预处理器会在编译前,根据 USE_LONG 是否被定义,将代码中所有的 INT_32 替换成 longint。这样,开发者就能确保 ip_address 变量在不同平台上都是32位整数。

预处理器在实际项目中的应用:以早期网页浏览器为例

查看1994年X Mosaic网页浏览器(首个跨平台浏览器)的部分网络连接代码,会发现大量 #ifdef 指令。这是因为当时不同操作系统(如SunOS, VMS, NeXT)的网络库实现存在差异。

例如,错误信息的获取方式可能不同:有的系统使用函数返回值,有的则写入特定的全局变量(extern变量)。通过条件编译,同一份C源代码可以根据目标操作系统的不同,编译出适配的代码段,从而实现真正的跨平台。

#ifdef VMS
    // VMS操作系统特定的错误处理代码
#elif defined(NeXT)
    // NeXTSTEP操作系统特定的错误处理代码
#else
    // 其他Unix系统的标准错误处理代码
#endif

这体现了C语言可移植性的另一面:语言本身是稳定的,但运行环境在变化。预处理器帮助我们让代码能“穿越时间”,在多年后和不同的系统上依然可以编译运行。虽然如今许多库已标准化,但条件编译在系统编程和跨平台项目中仍然重要。


总结 📚

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

  1. 递归:函数调用自身的技术。其核心在于调用栈的管理,每次调用产生一个栈帧,并且必须设有基线条件以防止无限递归。
  2. C预处理器:在编译前处理源代码的工具。它通过 #include 展开文件、通过 #define 进行宏替换、通过 #ifdef 等实现条件编译,极大地增强了C代码在不同平台和不同时期的可移植性

理解递归有助于你编写处理分层或树形数据的优雅代码,而掌握预处理器则能让你更好地管理和维护需要适应多种环境的C语言项目。

016:指针与函数的历史背景 📚

在本节课中,我们将学习C语言中指针与函数的核心概念,特别是它们在“按值调用”与“按引用调用”中的作用。我们将探讨指针的基本语法、历史背景,并通过与其他编程语言的对比来加深理解。


指针基础示例 🔍

以下是理解指针的关键示例。我们有两个变量 int xy,以及一个指针变量 px,其类型为 int *,表示它指向一个整数。

我们将42存储在 x 中,然后使用取地址运算符 &x 的地址存入 px。接着,我们使用解引用运算符 * 通过 px 获取该地址处的整数值,并将其赋给 y

int x, y;
int *px;
x = 42;
px = &x;
y = *px;

因此,当我们打印时,x 是42,y 是42,而 px 是一个长的十六进制数,代表计算机内存中的某个位置。&* 以及 int * 中的 * 作为类型修饰符是重要的概念。


Python中的id函数与地址 🐍

在Python中,你可能从未见过 id 函数。我们使用过像 typedir 这样的函数。id 是一种询问变量或常量标识的方法。

在CPython(用C实现的经典Python版本)中,id 函数返回的值类似于地址。但需要明确的是,Python有多个实现版本,id 的这种行为是CPython的实现细节,其他Python实现可能不遵循此规则。

如果你打印 xid(x),它类似于地址。但文档指出,Python的 id 函数并非设计为可解引用的,意味着我们不应该用它来查找内存。在CPython中,它基于内存地址,但这在其他Python实现中并不成立。


按引用调用与按值调用 🔄

指针使我们能够实现“按引用调用”。如果你使用过Python,你会知道它只支持“按值调用”。这意味着在函数内部修改参数不会影响外部变量。但有些语言支持“按引用调用”,允许函数内部修改调用者传递的变量值。

支持按引用调用的语言包括Pascal、C、C++、PHP和C#。不支持的语言包括Python、Java和JavaScript。需要注意的是,对于简单类型(如整数),Python是按值传递。对于对象,传递的是对象引用,你可以通过调用对象的方法来修改其内部数据,但这并非直接改变对象本身。


不同语言的按引用调用示例 🌐

以下是几种编程语言实现按引用调用的方式对比。

Pascal示例

Pascal语言由尼古拉斯·沃斯于1970年在瑞士创建,它明确支持按引用调用。在函数参数中使用 var 关键字表示按引用传递。

procedure funk(a: integer; var b: integer);
begin
    a := 1;
    b := 2;
end;

在主程序中调用 funk(x, y) 后,y 的值会被改变,而 x 不会。

C示例

在C语言中,我们通过传递变量的地址(使用 & 运算符)来实现类似按引用调用的效果。

void funk(int a, int *pb) {
    a = 1;
    *pb = 2;
}

int main() {
    int x = 42, y = 43;
    funk(x, &y);
    // 此时 y 变为 2,x 仍为 42
    return 0;
}

地址本身是按值传递的,但通过解引用指针 *pb,我们可以修改该地址处的值。

Python的替代方案

Python(1989年)本身没有按引用调用的概念。一个优秀的折中方案是使用元组返回多个值。

def funk(a, b):
    a = 1
    b = 2
    return a, b

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/1678f5a24a6322ca24b22e360715137e_14.png)

x, y = 42, 43
x, y = funk(x, y)

如果确实需要从函数内部改变多个变量的值,可以返回一个元组并在主程序中进行赋值。

PHP示例

PHP(1994年)提供了一种优雅的实现。在函数参数中使用 & 表示按引用传递。

function funk($a, &$b) {
    $a = 1;
    $b = 2;
}

$x = 42;
$y = 43;
funk($x, $y);
// 此时 $y 变为 2,$x 仍为 42

注意,PHP中变量以 $ 开头,这只是变量名的一部分。在函数调用和定义中,语法几乎不变,按引用调用即可工作。

C#示例

C#(2000年)使用 ref 关键字来实现按引用调用,这让人回想起Pascal。

void Funk(ref int a, ref int b) {
    a = 1;
    b = 2;
}

int x = 42, y = 43;
Funk(ref x, ref y);
// 此时 x 变为 1,y 变为 2

调用代码必须使用 ref 关键字,明确表示知晓该参数可能在函数中被修改。

在C语言中,我们通过 &* 运算符来实现。只要你能很好地理解C语言中星号和与号的作用,这在C代码中是非常直接的。


指针算术 📏

理解指针算术的关键在于,指向整数的指针与指向字符的指针是不同的。

虽然这两种指针的大小相同(因为它们都是地址,而地址大小相同),但当你给字符指针加1时,地址实际增加1个字节;而给整数指针加1时,地址增加4个字节(假设 int 类型占4个字节)。

char *cp;
int *ip;
cp++; // 地址增加 1
ip++; // 地址增加 4

这是因为每个整数占用4个字符(字节)的空间。因此,在进行指针的递增和递减运算时,指针所指向的类型比它本身是一个指针这一事实更重要。指针不仅仅是地址,它是指向具有特定类型数据的地址。


总结 ✨

本节课我们一起学习了C语言中指针与函数的核心概念。我们探讨了指针的基本语法,如何通过指针实现“按引用调用”,并对比了C、Pascal、Python、PHP和C#等语言在参数传递机制上的异同。我们还了解了指针算术,即指针运算会根据其指向的数据类型大小进行缩放。理解这些概念是掌握C语言内存管理和高效编程的关键。

017:指针与数组的历史背景与安全考量 🧠

在本节课中,我们将学习指针的历史背景,理解为何早期C语言中指针与整数可以混用,以及这种设计如何引出了现代C语言中的void*指针。我们还将探讨一个由指针和数组操作不当引发的经典安全问题:缓冲区溢出。


指针不是整数 📜

上一节我们介绍了指针的基本概念。本节中,我们来看看指针与整数的历史关系。

指针不是整数。回顾第2章,书中有一个关于不同系统数据类型大小的表格。

以下是早期计算机系统中整数和地址的位数对比:

  • PDP-11:整数为16位,地址为16-32位。
  • Honeywell 6000:整数为36位,地址为36位。
  • IBM 370:整数为32位,地址为24或31位。
  • Interdata 8/32:整数为32位,地址为32位。

通过比较整数和地址的位数可以发现,除了PDP-11,其他系统中整数的位数都大于或等于地址的位数。这意味着地址通常可以放入一个整数中,并且有多余的空间。因此,我们几乎可以把地址当作无符号整数来处理。

PDP-11的情况有些特殊,其地址范围在16到32位之间,这是因为不同时期交付的计算机配置不同。并非所有计算机都安装了最大内存,也并非所有应用程序都会使用计算机的全部内存。因此,在大多数情况下,你可以方便地将一个地址存入整数,然后再取出来,而不会截断或破坏该地址。

所以,将指针当作整数来处理,在历史上几乎是可行的。而且,年代越早,这种做法越可能成功。

地址通常是正数,常常从零开始。有时堆地址向下增长,有时栈地址向上增长。但大多数计算机并未安装最大内存。在多用户计算机上,你也不会将所有系统内存分配给每一个应用程序。早期的应用程序非常节省内存,因此很少遇到内存地址无法放入整数的问题。

所以,在70年代早期,应用程序可以编写一个返回地址的函数,将其作为整数返回,然后无需转换就直接复制到指针中。


void*指针的出现 🎯

上一节我们了解了早期指针与整数的关系。本节中,我们来看看现代C语言如何通过void*指针来解决类型安全问题。

到了80年代早期,void指针的概念为我们提供了一种表示通用地址的方式,即指向未知类型数据的指针。因为所有地址都是地址,只是它们指向的数据类型不同。

malloc函数为例,我们将在下一章更深入地使用它。malloc函数的作用是分配指定字节数的内存,并返回一个指向这片新内存的指针。

在70年代早期,malloc返回一个int(整数)。然后我们会将其强制转换(cast)为我们想要的任何类型。例如:

int *p = (int *) malloc(42);

malloc(42)会给我们一个整数形式的地址,然后我们将其强制转换为int*(指向整数的指针)。这是一个无损的转换。

到了1978年的K&R C书中,我们倾向于将其称为char*,因为42表示我们要分配多少个字符。然后你会将这个指向字符的指针,强制转换为指向整数的指针。所以malloc(42)实际上会给我们14个整数(假设int占4字节)。

但在现代C语言中,我们有了void*指针。它基本上是说:malloc将返回一个地址,你必须将其转换为某种具体类型。

所以,malloc(42)返回一个void*,然后被强制转换为int*。这是一个无损的转换,不会让编译器困惑,然后我们将其存储在我们的int*变量中。

你将来接触的所有代码都会使用void*。这里只是给你一点历史背景,解释为什么在1978年的书中没有提到void*


缓冲区溢出:一个经典的安全漏洞 ⚠️

上一节我们介绍了void*指针。本节中,我们将探讨一个因不当处理内存(尤其是字符串数组)而导致的严重安全问题。

每次在课堂上讲到“是时候学习安全了”,总会听到一些抱怨。但作为软件开发者,我们必须意识到,我们构建的东西可能会被恶意破坏。现在,是时候讨论这个问题了。

对于C语言,可能整个计算史上(从1950年至今,甚至在C语言出现之前)最严重的安全漏洞就是所谓的缓冲区溢出

这与一个事实有关:C语言中的字符串没有运行时长度概念。它有一个分配的长度,但没有一个记录当前长度的变量。因此,当我们向一个字符串中存入超过其容量的数据时,数据会继续存储在字符串的末尾之外,它不会自动分配更多空间。

以下是一个来自维基百科的示例,展示了一个8字符的字符串,后面跟着一个2字节的整数。当我们复制一个9字符的字符串(包含9个字符和结尾的\0空字符)时,仅仅因为试图写入A字符串,就完全覆盖了B变量。

这就是缓冲区溢出。它就像是向这个变量里塞入了太多东西,以至于超出了它被分配的空间,而且这个过程永远不会被检测到。这意味着你可以利用缓冲区溢出来做各种各样的事情:改变变量、开启超级用户权限等等。你需要查看源代码,精心构造一个攻击,但攻击的载体就是:当我们向字符串数组复制数据时,其边界不会被检查。如果你编写了糟糕的代码,或者系统本身有糟糕的代码,它就会去破坏内存。

事实证明,这个问题最严重的“肇事者”之一是gets函数。它曾是标准C库的一部分很长时间。

以下是一个使用gets的简单示例程序:

#include <stdio.h>
int main() {
    char s[15]; // 一个15元素的字符数组
    gets(s);    // 危险!不检查边界
    printf("%s\n", s);
    return 0;
}

当你编译一个包含gets的代码时,编译器会发出警告,强烈建议你不要使用gets。但为了演示,我们仍然运行它。

程序运行时,C标准库甚至在提示输入之前就打印出一条警告信息:“你真的不应该使用gets”。如果你认为这个程序是可信的,那很可能错了。

  • 第一次运行:输入“hello world”(11个字符,加上\0共12个)。这适合s[15],程序正常运行。
  • 第二次运行:输入“hello”加上一堆空格和“world”,总共超过15个字符。程序打印出完整的输入,但它已经覆盖了s[15]之后的各种未知数据。因为smain函数中的自动变量,它在栈上,所以溢出会破坏栈上的其他内容。

C运行时会在栈上放置一些东西来标记或捕获这种溢出。因此,当代码执行完毕后,会看到“abort trap 6”错误。这是C运行时在说:我不会让这个程序继续执行了,因为有一个数组被破坏了。它并不是检测到了数组越界,它不知道数组有多长。它只是放入了字符。但它确实在数组后面放了一些东西(如金丝雀值),并在之后检查它。当这个值被覆盖时,运行时就知道出了问题并终止程序。

我们绝不希望你使用gets。这就是一个缓冲区溢出的简单例子。未来我们可能会看一些更复杂的例子,尝试利用类似gets的函数来操纵程序的行为,而不仅仅是让程序崩溃。


总结与展望 🎓

本节课中,我们一起学习了指针的历史背景、void*指针的由来,以及由指针和数组操作不当引发的经典安全问题——缓冲区溢出。

指针是C语言中最美妙、最强大的部分。它们很复杂,但本质上,指针使得高级语言能够像低级语言一样工作。如果没有指针(我指的不是Python那种隐式的引用,而是我们可以显式查找、解引用的正式指针),我们就无法完成操作系统需要做的事情,那些我们过去需要用汇编语言来写的事情,比如:这里有一个内存缓冲区,我们要复制它;那里有另一个缓冲区;还有一个链接所有不同缓冲区的链表。

理解指针将引导你通向汇编语言、机器语言,并最终理解硬件。因此,你不应该匆忙略过这部分内容。指针真的非常重要。从现在开始,我们要做的一切都将与指针息息相关。

教材的第5.7和5.10节(或5.3、5.12节,取决于版本)内容有些密集。我真正希望的是,你能理解我刚才讲的内容以及对应的章节。第6章会更有趣,因为我们将更多地使用指针,而不仅仅是讨论“什么是指针”。

018:城市办公室巡访时间 🏙️

在本节课中,我们将跟随Chuck的镜头,探访密歇根大学应用数据科学硕士(MADS)项目的一位学生,了解他的学习体验与时间管理策略。本节不涉及具体编程技术,而是分享一种平衡工作、学习与个人项目的方法。


大家好,我是Chuck。我现在在特拉弗斯城,正在Cepesson公司吃早餐。我们坐在餐桌对面,开玩笑地谈论着伯克利和密歇根等事情。结果发现,我们这里有一位来自密歇根大学应用数据科学硕士项目(MADS)的学生,名叫Chris。我想请你打个招呼。Chris,向所有其他MADS的同学们问好吧。大家加油,Go Blue!

你现在学到项目中期了,正在上哪些课?我正在上511课程,这是我目前最喜欢的一门课。我也很喜欢505课程。目前我正在休暑假,但秋季学期会回去上课。

作为一名教员,我发现MADS项目的节奏非常快。我很好奇你们学生是如何应对的。我认为它的节奏是最快的。我实际上是兼职学习,同时全职从事抵押贷款领域的数据科学家工作。我的学习计划大约跨越四年,节奏放得比较慢。我会非常详细地学习材料,并且经常在课余时间根据自己的学习内容做一些个人项目。

这样,我就不用试图在九个月内仓促完成所有课程,而是按照自己的节奏学习,并在这个项目过程中逐步建立自己的作品集。这真是个我没想到的好主意。我确实没这么想过,也确实不能学得太快。我认为只要保持连贯性就好。你不想休息太长时间,因为这个项目强度很大,如果间隔太久,可能会忘记之前学的内容。这是我一年半以来的第一个休假学期。

好的,我同意。那么我们就聊到这里,你可以向所有的同学们问好。Go Blue!


本节课中我们一起学习了,即使在一个快节奏的学术项目中,通过采取兼职学习、按照个人节奏深入理解材料、并同步进行相关个人项目的方式,可以有效管理时间、深化知识并构建实践作品集。这是一种平衡高强度学习与长期职业发展的实用策略。

019:通过诗歌解读K&R C第6章 📖

在本节课中,我们将开始学习K&R C语言教材的第6章。本章的核心内容是结构体,但它的意义远不止于此。本章是课程的一个关键转折点,我们将从学习C语言的基础语法,过渡到应用这些语法来构建更复杂的数据结构。这是一个挑战,但也是通往更广阔计算机科学世界的大门。

章节概述与转折点

上一章我们学习了指针等核心概念。本节中,我们来看看第6章的整体结构及其重要性。

第6章的前四节(6.1至6.4)将继续教授C语言本身,重点介绍结构体这个概念。结构体是一种将多个不同类型的数据组合在一起,形成一个新类型的优雅方式。它是C语言课程中最后一个基础性的组成部分。

然而,从第6.5节开始,作者将话题转向了数据结构。这是结构体概念的应用,也是计算机科学的基础。例如,你将学习如何在C语言中构建类似Python字典的功能。这个转折点常被称为“学习曲线的膝盖部分”——在此之前,学习循环、字符串、数组甚至指针都还算顺利,但应用结构体来构建数据结构的难度会显著提升。

因此,如果你之前学得很快,从这里开始,我建议你放慢速度,专注于理解和掌握。这些概念并不天然容易理解,但一旦掌握,你将打开计算机科学的一扇大门,甚至在本章末尾会接触到递归这样的概念。请不要急于求成。

一首诗的启示

在深入技术细节之前,我想分享一首对我有特殊意义的诗,它来自罗伯特·弗罗斯特。这首诗提醒我们,学习的旅程漫长而并非易事,但坚持走下去是值得的。

以下是罗伯特·弗罗斯特的《雪夜林边小驻》:

我想我认识这片森林的主人,
他的家虽在村子,却不见他身影;
他不会看到我停留在此地,
凝视他的树林积雪层层。

我的小马一定觉得奇怪,
为何停在远离农舍的野外,
在这森林与冰湖之间,
一年中最黑暗的夜晚。

它摇动一下颈上的铃铛,
询问是否出了什么状况。
唯一的其他声音是微风,
和鹅毛雪片扫过的轻响。

树林可爱,幽暗而深邃,
但我还有诺言需要遵守,
安睡之前还有许多路要走,
安睡之前还有许多路要走。

这首诗的精髓在于,你历尽艰辛才学到本书的第6.4节,或许觉得已经足够,可以自我褒奖。但第6.4节之后,依然是“安睡之前还有许多路要走”。好消息是,当你完成这段旅程,便可以放松休息。所以,我希望你保持耐心,稳步前进。接下来的内容复杂度会迅速增加,我不希望任何人掉队。

本节课中我们一起学习了第6章的重要性及其承上启下的地位,并通过一首诗体会了坚持学习的意义。从下一节开始,我们将正式进入结构体这一核心概念的学习。

020:结构体(第一部分)

在本节课中,我们将学习C语言中一个非常核心且强大的概念——结构体。结构体允许我们将不同类型的数据组合成一个单一的、自定义的数据类型,这对于组织复杂数据至关重要。我们将从基础定义开始,逐步深入到结构体与指针、动态内存分配的结合,并最终构建一个简单的链表数据结构。

🏗️ 5.03.02:结构体基础

结构体是C语言中最优美的部分之一,就像指针一样。它是一种用户自定义的类型,可以包含一个或多个其他类型的数据。

我们称结构体内部的变量为“成员”。例如,在下面的结构体中,xy 都是 struct point 的成员。

点运算符 . 允许我们访问结构体变量的成员。我们通过一个结构体变量,后接点运算符和成员名,来访问或修改该成员的值。

以下是一个基础示例,代码文件为 KR0601.c

struct point {
    double x;
    double y;
};

这段代码定义了一个名为 point 的新类型,它包含两个 double 类型的成员 xy。注意,这仅仅是类型定义,并未分配任何内存。

struct point p1, p2;

这行代码才真正分配了两个 point 类型的变量 p1p2,每个变量包含两个 double,共占用16字节(假设 double 为8字节)。

p1.x = 3.0;
p1.y = 4.0;
p2 = p1;
printf(“%f %f\n”, p2.x, p2.y); // 输出:3.0 4.0

我们为 p1 的成员赋值,然后将 p1 整个赋值给 p2。这个操作会将 p1 的所有成员值复制到 p2 的对应成员中。最后打印 p2 的值,得到3和4。这就是结构体的基本用法。

🔄 5.03.02:结构体作为函数参数(传值)

结构体可以像其他基本类型一样作为函数参数传递。默认情况下,C语言对结构体采用“传值”调用。

这意味着当我们将一个结构体变量传递给函数时,整个结构体的内容会被复制到函数的栈帧中。因此,函数内部操作的是原始数据的一个副本,不会影响调用者中的原始结构体。

考虑以下情况:如果结构体非常大,这种复制可能会消耗较多内存和时间,所以需要谨慎设计结构体的大小。

以下是传值调用的示例:

void funk(struct point pf) {
    pf.x = 9.0; // 修改副本的成员
    pf.y = 8.0;
    printf(“Inside funk: %f %f\n”, pf.x, pf.y);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/31bfcd4c175bf603475ce9fd0a836013_16.png)

int main() {
    struct point pm = {3.0, 4.0};
    funk(pm); // 传递pm的副本
    printf(“In main: %f %f\n”, pm.x, pm.y); // pm的值未改变
    return 0;
}

在函数 funk 中,我们修改了参数 pfxy 值。但由于 pf 只是 pm 的一个副本,所以当函数返回后,main 函数中的 pm 保持不变。输出将显示函数内部的值被修改了,但 main 中的值仍是3和4。

🎯 5.03.02:指向结构体的指针

为了更高效地操作结构体,并允许函数修改原始数据,我们通常使用指向结构体的指针。这是结构体真正发挥威力的地方。

我们可以像获取整型变量地址一样,使用 & 运算符获取结构体变量的地址。

以下是定义和使用结构体指针的步骤:

  1. 定义指针struct point *pp; 声明了一个指向 struct point 类型的指针。
  2. 获取地址pp = &p; 将结构体变量 p 的地址赋值给指针 pp
  3. 通过指针访问成员:有两种等效的方法:
    • 解引用后使用点运算符:(*pp).x = 3.0;
    • 使用箭头运算符(更简洁):pp->y = 4.0;

箭头运算符 -> 是“解引用并访问成员”的简写形式,在C语言中极为常用。许多其他语言(如PHP)也借鉴了这个操作符。

📞 5.03.02:结构体作为函数参数(传地址/传引用)

通过传递结构体的指针(即地址),我们可以在函数内部修改原始结构体的内容。这通常被称为“传地址”或“传引用”。

以下是传地址调用的示例:

void funk(struct point *pp) { // 参数是指针
    pp->x = 9.0; // 通过指针修改原始数据
    pp->y = 8.0;
    printf(“Inside funk: %f %f\n”, pp->x, pp->y);
}

int main() {
    struct point pm = {3.0, 4.0};
    funk(&pm); // 传递pm的地址
    printf(“In main: %f %f\n”, pm.x, pm.y); // pm的值已被改变
    return 0;
}

在调用 funk(&pm) 时,我们将 pm 的地址压入栈帧传递给函数。函数内部通过指针 pp 和箭头运算符直接操作 pm 所在的内存。因此,函数内的修改会直接影响 main 函数中的 pm 变量。输出将显示,无论在函数内部还是外部,pm 的值都变成了9和8。

💾 5.03.02:动态内存分配与结构体

到目前为止,我们创建的结构体变量都是静态分配的(在栈上)。但很多时候,我们需要在程序运行时动态地创建结构体,这就需要用到堆内存和 malloc 函数。

首先,我们需要知道要分配多大的内存。sizeof 运算符可以返回一个类型或变量所占用的字节数。

struct point p;
struct point *pp;
printf(“Sizeof struct: %lu\n”, sizeof(struct point)); // 输出 16 (2个double)
printf(“Sizeof pointer: %lu\n”, sizeof(pp)); // 输出 8 (一个地址的大小)

malloc 函数(需要包含 stdlib.h)用于在堆上分配指定大小的内存,并返回一个指向该内存块的指针(类型为 void*)。我们通常需要将其强制转换为目标指针类型。

以下是动态分配结构体的示例:

#include <stdlib.h>
int main() {
    struct point *pp; // 仅分配了一个指针变量(8字节)
    // 动态分配一个 struct point 所需的内存
    pp = (struct point *) malloc(sizeof(struct point));
    if (pp == NULL) {
        // 处理内存分配失败
        return 1;
    }
    // 使用分配好的结构体
    pp->x = 5.0;
    pp->y = 10.0;
    // ... 使用完毕后
    free(pp); // 释放内存,防止内存泄漏
    return 0;
}

关键步骤:

  1. 声明一个结构体指针 pp,此时它还未指向任何有效的结构体数据。
  2. 使用 malloc(sizeof(struct point)) 请求足够存放一个 struct point 的内存。
  3. 将返回的 void* 指针强制转换为 struct point* 类型,并赋值给 pp
  4. 现在,pp 指向了一块有效的内存,我们可以通过 pp->x 等方式访问和修改其成员。
  5. 使用完毕后,务必使用 free(pp) 释放内存。

🔗 5.03.02:构建链表数据结构

现在,我们将结合动态内存分配和结构体,来构建一个基础的数据结构——链表。链表允许我们动态地存储和管理数量可变的数据项,类似于Python中的列表。

首先,我们来看一个简单的Python程序,它读取文件并存储每一行到一个列表中:

lines = list()
hand = open(‘romeo.txt’)
for line in hand:
    lines.append(line.rstrip())
for line in lines:
    print(line)

我们的目标是在C语言中实现类似的功能。链表中的每个元素(称为“节点”)将存储在动态分配的内存中。每个节点包含两部分数据:

  1. 实际的数据(例如,一个字符串)。
  2. 一个指向下一个节点的指针。

我们定义链表节点的结构如下:

struct lnode {
    char *text;        // 指向字符串数据的指针
    struct lnode *next; // 指向下一个节点的指针
};

为了管理链表,我们通常需要两个全局指针:

  • struct lnode *head;:指向链表的第一个节点。
  • struct lnode *tail;:指向链表的最后一个节点。

初始时,链表为空,headtail 都应为 NULL

一个包含三个节点(”C”, “is”, “fun”)的链表示意图如下:
head -> [“C” | next] -> [“is” | next] -> [“fun” | next] -> NULL
同时,tail 指向最后一个节点 [“fun” | next]。

向链表追加节点

以下是向链表末尾添加一个新节点的逻辑步骤。假设我们从文件中读取了一行字符串 line

  1. 为字符串数据分配内存:我们需要复制该字符串,因为原始输入缓冲区可能被覆盖。
    char *save = (char *) malloc(strlen(line) + 1); // +1 用于存放字符串结束符 ‘\0‘
    strcpy(save, line); // 将 line 的内容复制到新分配的内存 save 中
    
  2. 为新节点分配内存
    struct lnode *new = (struct lnode *) malloc(sizeof(struct lnode));
    
  3. 设置新节点的内容
    new->text = save;   // 节点的 text 指针指向我们保存的字符串
    new->next = NULL;   // 新节点目前是最后一个,所以 next 指向 NULL
    
  4. 将新节点链接到链表
    • 如果链表为空(head == NULL),则新节点既是头也是尾:head = new; tail = new;
    • 如果链表不为空,则将当前尾节点的 next 指向新节点,然后更新 tail 指向新节点:
      if (tail != NULL) {
          tail->next = new;
      }
      tail = new; // 更新尾指针
      if (head == NULL) {
          head = new; // 如果是第一个节点,同时设置头指针
      }
      

遍历链表

遍历链表意味着按顺序访问每一个节点。我们使用一个临时指针 currenthead 开始,逐个节点移动,直到遇到 NULL

struct lnode *current;
for (current = head; current != NULL; current = current->next) {
    printf(“%s\n”, current->text);
}

这个过程类似于Python中的 for item in list: 循环。在每次迭代中,current = current->next 将指针移动到链表中的下一个节点。

重要建议:在处理链表这类指针结构时,动手画图是理解节点间连接关系的最有效方法。通过图示,插入、删除和遍历等操作会变得清晰直观。

📚 本节课总结

在本节课中,我们一起深入学习了C语言的结构体。我们从最基础的结构体定义和成员访问开始,了解了如何使用点运算符。接着,我们探讨了结构体作为函数参数时的“传值”特性,并指出其对大结构体的效率影响。

然后,我们引入了指向结构体的指针和箭头运算符 ->,这让我们能够高效地“传地址”给函数,从而在函数内部修改原始结构体数据。我们还学习了如何使用 sizeof 运算符和 malloc 函数在堆上动态分配结构体内存,这是构建动态数据结构的基础。

最后,我们综合运用这些知识,构建了一个简单的单向链表数据结构。我们定义了节点结构,实现了向链表追加节点的逻辑,以及遍历链表的方法。链表是理解更复杂数据结构(如树、图)的基石,掌握其原理对于成为一名优秀的程序员至关重要。记住,在操作链表时,画图是你最好的朋友。

021:第2部分-K&R C第6章结构体(1978年版)

在本节课中,我们将学习链表的高级操作,包括如何从链表中删除节点,以及双向链表和联合体(union)的概念。我们将通过图示和简单的解释来理解这些核心数据结构的工作原理。

链表删除操作

上一节我们介绍了链表的基本概念和遍历。本节中我们来看看如何从链表中删除一个节点。删除操作需要处理三种不同的情况:删除中间节点、删除头节点和删除尾节点。处理这些情况的关键是始终跟踪当前节点(cur)和前一个节点(prev)。

以下是删除链表节点的三种情况:

  1. 删除中间节点:这是最简单的情况。找到目标节点后,只需将前一个节点(prev)的 next 指针指向当前节点(cur)的下一个节点(cur->next),从而绕过要删除的节点。最后释放被删除节点的内存。
    prev->next = cur->next;
    free(cur->data); // 假设存储了数据
    free(cur);
    

  1. 删除头节点:当要删除的节点是链表的第一个节点时,前一个节点(prev)为 NULL。此时,需要将链表的头指针(head)指向当前节点的下一个节点(cur->next)。
    if (prev == NULL) {
        head = cur->next;
        free(cur->data);
        free(cur);
    }
    

  1. 删除尾节点:当要删除的节点是链表的最后一个节点时,当前节点(cur)的 next 指针为 NULL。此时,需要将前一个节点(prev)的 next 指针设为 NULL,并将链表的尾指针(tail)指向前一个节点(prev)。
    if (cur->next == NULL) {
        prev->next = NULL;
        tail = prev;
        free(cur->data);
        free(cur);
    }
    

双向链表

现在,我们来看看双向链表。双向链表的主要目的是能够轻松地反向遍历链表。在Python中,列表对象的 reverse() 方法内部很可能就使用了双向链表。

在双向链表中,每个节点不仅包含指向下一个节点的指针(next),还包含指向前一个节点的指针(prev)。链表头节点的 prev 指针为 NULL,尾节点的 next 指针为 NULL

以下是双向链表节点的结构定义示例:

struct DLNode {
    char *data;
    struct DLNode *prev;
    struct DLNode *next;
};

反向遍历双向链表

使用双向链表进行反向遍历非常简单。我们只需要从尾节点(tail)开始,沿着 prev 指针向前移动即可。

以下是反向遍历的代码逻辑:

struct DLNode *current = tail;
while (current != NULL) {
    printf("%s\n", current->data); // 处理当前节点数据
    current = current->prev; // 移动到前一个节点
}

联合体(Union)

最后,我们介绍联合体(union)。联合体与结构体(struct)类似,但关键区别在于:结构体的成员占用不同的内存空间,而联合体的所有成员共享同一块内存空间

这意味着,联合体的大小由其最大的成员决定。你可以通过不同的成员名来访问和解释同一块内存数据,这在处理网络协议、硬件寄存器或需要节省内存时非常有用。

以下是一个联合体的定义和使用示例:

union Sample {
    int i;      // 通常占4字节
    char ca[4]; // 占4字节
    float f;    // 通常占4字节
};

union Sample u;
u.i = 42; // 将同一块内存解释为整数并赋值
printf("As integer: %d\n", u.i);
printf("As string: %s\n", u.ca); // 将同一块内存解释为字符数组(可能输出乱码)
u.f = 1.0/3.0; // 将同一块内存重新解释为浮点数并赋值
printf("As float: %f\n", u.f);

总结

本节课中我们一起学习了链表的高级操作。我们详细分析了从单链表中删除节点的三种情况及其处理方法。接着,我们引入了双向链表的概念,了解了它如何通过 prevnext 两个指针实现高效的反向遍历。最后,我们探讨了联合体(union),理解了它与结构体的核心区别——共享内存,并看到了它在多种数据解释场景下的应用。掌握这些概念将为理解更复杂的数据结构和底层编程打下坚实基础。

022:面向对象编程的历史视角

在本节课中,我们将从历史视角审视面向对象编程。我们将回顾面向对象的核心概念,探索不同编程语言中面向对象思想的演变,并了解C语言如何成为现代面向对象语言的基础。最后,我们将看到如何在C语言中构建类似Python的类。

概述

面向对象编程是一种概念,而不仅仅是语法。通过观察不同语言的语法实现,我们可以更好地理解其底层概念。本节课将简要回顾面向对象术语,并探讨其在不同编程语言中的历史发展路径。

面向对象术语回顾

上一节我们概述了课程内容,本节中我们来回顾一下面向对象编程的核心术语。这些术语对于理解后续内容至关重要。

  • :类不是对象,它是创建对象的模板。可以将其比作制作饼干的模具。
  • 属性:属性是包含在类每个实例中的数据。
  • 方法:方法是类似于函数的代码,它在类实例的上下文中运行。
  • 对象:对象是类的一个特定实例,当请求类的新实例时,由类“印刻”出来。一个类可以拥有许多个实例。

以下是Python中一个用户定义类的示例:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def dump(self):
        print(f'Point at {id(self)}: x={self.x}, y={self.y}')

    def origin(self):
        return (self.x**2 + self.y**2)**0.5

    def __del__(self):
        print(f'Deleting point at {id(self)}')

在这个例子中:

  • Point 是类。
  • __init__ 是构造函数,xy 是传入的参数,self.xself.y 是实例的属性。
  • dumporigin 是方法。方法内部的第一个参数按惯例称为 self,它代表实例本身。
  • __del__ 是析构函数。

创建和使用对象的代码如下:

pt = Point(4, 5)  # 调用构造函数创建实例
pt.dump()         # 调用 dump 方法
print(pt.origin()) # 调用 origin 方法
# 程序结束时,析构函数 __del__ 会自动运行

编程语言的历史脉络

理解了基本术语后,我们来看看面向对象思想在编程语言历史中的演变。这有助于我们理解为何不同语言的面向对象实现方式各异。

C语言对现代过程式编程语言的语法演变产生了深远影响。然而,面向对象思想的启发和演变却走了一条不同的路径,贯穿了一系列你可能熟悉或不熟悉的语言。

以下是关键语言及其关系的简要时间线:

  • 1955年 Fortran1960年 Algol 60:Algol 60在一定程度上是对Fortran的回应,它更受当时计算机科学家的青睐。
  • 1967年 S:从Algol 60中吸收了许多面向对象思想。
  • 1970年 Pascal:一种过程式语言。
  • 1972年 C:出现后极大地改变了我们对语法的思考方式,并启发了C++、Java、JavaScript、C#和PHP。
  • 早期面向对象语言:Simula和Smalltalk等语言主要是过程式的,但包含了面向对象概念。Smalltalk尤其被认为是发展了最纯粹的面向对象概念的语言之一。
  • 函数式语言的贡献:1960年代初的Lisp及其1975年的衍生语言Scheme,也包含了对象概念,为面向对象模式提供了不同的灵感来源。

现代语言的面向对象传承

了解了早期历史后,本节我们来看看现代流行语言如何从不同的源头继承和发展了面向对象特性。

现代语言的面向对象特性有着复杂的传承关系:

  • C++ (1980年代初):从Simula(面向对象概念)和C(过程式语法)中汲取灵感,是一种试图在C语法之上叠加面向对象概念的混合语言。
  • Python (1991年):设计时充分意识到了C++的存在,几乎在语言实现之初就内置了面向对象特性。
  • Java (1995年):试图成为“下一个C”,从C和C++中借鉴了大量内容。
  • JavaScript (1995年):虽然受Java影响而诞生,但其面向对象模式更多源自Scheme,采用了更纯粹的、基于原型的面向对象方法,而非基于类的层叠方式,因此在这些语言中显得与众不同。
  • PHP (1995年/2000年):1995年首次发布时并非面向对象语言,直到2000年才添加了对象支持。
  • C# (2001年):灵感来源于C++和Java。

总结

本节课中,我们一起学习了面向对象编程的历史视角。我们回顾了类、对象、属性和方法等核心术语。更重要的是,我们看到了面向对象作为一种编程概念,其思想是如何在不同编程语言(如Simula、Smalltalk、Lisp/Scheme)中独立演化,并最终以不同方式(如C++的混合模式、JavaScript的原型模式、Python的早期内置支持)融入到以C语法为根基的现代语言(C++、Java、Python、JavaScript等)中。理解这段历史有助于我们看清语法背后的统一概念,并为后续学习如何在C语言中实现面向对象支持打下基础。

023:贝尔实验室的C与C++演进 🏛️💻

在本节课中,我们将了解C语言与C++在贝尔实验室的共同演进历程,探讨C++如何从C语言中诞生,以及两者之间的相互影响。


概述

本节内容基于布莱恩·柯林汉的讲述,回顾了C语言与C++在贝尔实验室的早期发展。我们将看到C++如何作为C语言的扩展被创造出来,以及这种设计决策背后的工程考量。


协作环境与共同兴趣

上一节我们介绍了编程语言发展的背景,本节中我们来看看孕育C和C++的特定环境。

当时的环境具有高度的协作性。这个大约由30人组成的团队,成员们对许多相同领域的事物都抱有浓厚兴趣。

尽管兴趣的触角伸向不同方向,例如理论计算机科学、数学以及物理科学等领域,但团队中至少有很大一部分人本质上是软件开发者。


C++的诞生:从Simula到C

以下是C++语言诞生的关键背景和设计思路。

加内尔于1979年获得剑桥大学博士学位后加入贝尔实验室。他对模拟仿真领域感兴趣。

他尤其了解Simula语言,这很可能是最早的面向对象语言之一。他想要进行模拟仿真,但C语言是贝尔实验室当时普遍使用的语言。

因此,他所做的是尝试将Simula语言中的一些优秀思想,特别是“类”的概念,移植到C语言之上。

在很长一段时间里,C++的实现方式基本上是将C++代码翻译成C代码,然后就可以在任何地方运行。这是比雅尼·斯特劳斯特鲁普做出的众多务实工程决策之一。


务实的设计哲学

如果你想推广一门新语言,如果它需要用户携带庞大的基础设施、支持库和其他包袱,那么很难让人们接受它。

反之,如果它只是一个额外的程序,能够完美地融入用户现有的环境,包括语言本身、库以及其他所有部分,那么推广起来就会容易得多。

因此,C++经历了一段进化时期,并且至今仍在进化。


C与C++的早期共生关系

从大约1980、81年代初期开始,这两种语言紧密地联系在一起。因为我们同属于贝尔实验室的一个小组,这个小组的规模恰好能舒适地容纳在这栋建筑的这条走廊里。

比雅尼·斯特劳斯特鲁普无疑对C语言了如指掌,他正在开发这种运行于C语言之上的新语言,这同时也对C编译器提出了考验。

这种做法是有益的,因为他的预处理器生成的代码质量令人惊叹。

我认为C++中的一些思想后来又被反向移植回了C语言。

其中最明显的一个例子就是如何声明函数的参数,仅仅这一点就更好。还有其他一些改进。

因此,有一段时间,你可以说C语言几乎是C++的一个完美子集。我认为后来两者都各自演进,现在这种说法已不如当时准确。

但在很长一段时间里,你可以直接拿一个C程序,用C++编译器编译,它就能运行。


机器生成代码的挑战

有一个普遍的观察是:人们编写代码的方式与计算机生成代码的方式不同。

因此,机器生成的代码往往会特别考验编译器或目标语言的极限。

在C++生成C代码的例子中,它们会生成嵌套层次极深的构造,其复杂程度让Lisp语言都显得温和。

同时还包括非常复杂的指针计算、函数指针等各种情况。这无疑是一种压力测试。

生成的代码还可能包含一些具有奇怪尺寸的结构,这些是编译器未曾预料或至少未经过充分测试的。


C++的设计权衡与接受度

我认为当时很多人并不认为C++是“正确”的,它在某些方面存在各种瑕疵和缺陷。

其中许多缺陷再次直接源于比雅尼·斯特劳斯特鲁普的工程判断:如果你希望这个东西能够流行起来,那么它与现有文化的兼容性越高,成功的可能性就越大。

如果你创造了一个截然不同的东西,人们很可能会忽略你。

因此,C++中至今仍存在的一些语法问题,你可以看到它们由来已久。

当我尝试向人们教授C++时,我会向他们展示如何将一个C++对象翻译成C代码。本质上,它只是指向结构的指针,编译器负责区分名称,所以你不需要考虑它们。

看到这种翻译过程,你就能理解面向对象编程如何能在几乎零开销的情况下实现,因为它只是结构体指针、有趣的函数名,并且可以传递函数指针。这一切都表现得相当良好。

这种理解帮助我明白了C++和面向对象编程背后的机制。

我认为在现代语言中,以Python为例,那里有大量的“魔法”在发生,我并不完全清楚它是如何运作得如此之好。

我的意思是,我可以大致想象,但像包含lambda表达式的列表推导式这样的机制,到底是如何工作的?


总结

本节课中我们一起学习了C++在贝尔实验室从C语言中诞生的历史。我们了解到,C++最初被设计为C的扩展,通过预处理器翻译成C代码以实现跨平台,这是一种务实的工程决策。早期C和C++紧密共生,C++的某些思想甚至反过来影响了C的演进。我们还探讨了机器生成代码对编译器的挑战,以及C++在设计上为求兼容和普及所做的权衡。理解C++底层的翻译机制(如对象即结构体指针)有助于深入把握面向对象编程的原理。

024:C++之父谈C++的设计哲学与核心应用领域

概述

在本节中,我们将跟随C++之父Bjarne Stroustrup的视角,探讨C++语言的设计初衷、核心应用领域以及其独特的编程哲学。我们将了解C++为何在特定领域(如基础设施软件)中至关重要,以及它如何通过类型安全、编译时计算等特性来构建高可靠性系统。


C++的核心应用领域

我试图描述C++在哪些方面特别有用。我最初构建它是为了什么?经过近30年的发展,它今天的优势是什么?同时,它的应用边界在哪里?

在我看来,C++有一个核心应用领域,这通常被称为“传统系统编程”,但这个术语并不准确,因为它更多指的是一种编程语言风格和编程范式。

因此,我更进一步思考:哪些应用需要C++提供的服务?我处理过的所有应用程序中,哪些是C++发挥作用且必不可少的?我提出了“基础设施”这个概念。

我大致将其定义为:如果它崩溃了,就会有人受伤或遭受损失。这些是我们系统中必须正常工作的基础性事物,是社会运转的基石。

我试图阐述的问题是:在这些领域中,什么才是最重要的?我总结出了一些关键概念。

以下是构建可靠基础设施软件所需的核心要素:

  • 紧凑的数据结构:高效利用内存和计算资源。
  • 强类型接口:用于提高可维护性和最小化错误。
  • 对算法的重视:相较于随意编写的代码,我们更需要可靠的算法,因为我们需要系统可理解、可分析,并确保其正确性。

我撰写的论文正是源于这种思考:对于必须可靠的、基础设施类的软件,什么才是正确的编程风格?我们需要什么样的语言特性来支持这种风格?

我们可以找到真实的例子。一些现代代数系统的核心、我们电话系统的基础、我汽车里的刹车系统。我们如何让这些系统变得可靠?


可靠性的挑战与解决方案

我们如何确保太空探测器在前往火星的半路上不会因为逻辑错误而蓝屏死机?我们无法派维修人员去修理。我们如何确保它们进入正确的轨道?

喷气推进实验室(JPL)曾丢失过一个火星探测器,因为两个团队沟通出现了问题。他们以为沟通顺畅,但实际上,一个团队使用英制单位,另一个使用国际单位制(SI,即公制)。结果导致了导航错误,将价值超过一亿美元的设备送入了错误的轨道。这不是一个好主意,这是200名优秀工程师毕生工作的心血付诸东流。

只需稍微改进程序各部分之间的接口,这种错误本可以避免。我对这类问题很感兴趣。

因此,存在一个核心领域,我认为C++提供的设施(尽管并不完美,以及我正在研究的那类特性)是必不可少的。

然后存在一个巨大的灰色区域,在那里你有选择。在我看来,C++可以提供帮助,但并非必不可少。最后,还有一些领域可能不适合应用那种严格的技术。如果我为自己开发一个小型应用,或者有人试图快速推出一个简单的网页应用,他们不需要那种级别的可靠性,也许我谈论的编程方式并不适合他们。

但我认为这里真正重要的是要认识到:不同的技术、不同的语言适用于不同的领域,我们必须认识到这一点。

我们不能为所有人使用一种简单的语言,也不能为所有人使用一种简单的语言使用技巧。我们不能拥有单一的工具链或单一类型的系统。


教育与思维方式的差异

由此,我们可以更进一步:我们不需要为所有工程师、软件开发人员或任何构建基础设施(例如,自动更新手机软件的机制)的人提供相同类型的培训和教育。

他们必须与开发小游戏的人接受不同的教育、拥有不同的知识、接受不同的训练。因为前者一个小小的失误,就可能毁掉数百万人的一整天甚至一整周。如果911核心服务无法接通,甚至可能有人受伤,糟糕的事情就会发生。

这不仅仅是运行在其上的软件,还包括更新软件,以及安全关键链条中的任何环节。但从事这项工作的人,其思维方式必须与编写一个小型应用(也许是为了快速处理几本书)的人不同。这本身没有错,但事实上,他们必须有不同的思维方式。

如果你将这种非常严格的、工程导向的思维应用到小型商业应用上,你可能会晚上市一年。另一方面,如果你将“唯快不破”的态度应用到我汽车的转向系统上,那可不是个好主意。这些人必须有不同的思维方式。

而让人们以不同方式思考的方法,就是给予他们不同的教育。我们没有一个“标准程序员”,也不应该有。如果我们有,那应该是土木工程师那样的标准。

我认为,这个领域必须在某种意义上自我整顿,否则就会有其他人来替我们整顿。


改进语法:用户自定义字面量

“你可以定义自己的常量,并在常量后添加后缀”这个特性,是你在某个中间阶段添加的功能,还是简单的运算符重载?

我们观察到,各种基本类型衍生出了大量的小后缀。例如,u 表示无符号(unsigned),l 表示长整型(long),等等。我记不清其他的了。有人曾想,在C++里你几乎可以做任何事情,但就是不能定义自己的字面量。

然后我单独观察到,有一些技术是有效的,但人们因为记法太丑陋而不愿使用。我展示的Unix例子就是其中之一。在过去的10年里,库一直是可用的,费米实验室就有一个不错的库,但它没有得到应有的广泛使用,因为用户不相信那种记法,他们不喜欢。

因此,我们研究的是,如何从根本上清理人们的源代码?我们如何让代码看起来像理想语言中的样子?如何让代码看起来尽可能像教科书里的样子?

所以,用户自定义字面量(units suffix)只是一种让你的代码看起来像物理教科书里方程的方式。我们知道如何避免那个火星气候轨道器的问题。每个人在高中物理课上都学过:首先,确保你的单位匹配,然后再进行详细计算。那为什么人们不这么做呢?因为这样做太繁琐,或者当他们这样做时记法太丑,或者如果他们使用运行时技术,成本太高。

因此,我和朋友们认为这个问题值得解决,试图找出我们已有的解决方案,并经历了多次演进。我认为最后的润色是由David完成的,现在它已经是标准的一部分了。这是一个目前尚未广泛普及的特性。


编译时计算与常量表达式

那个例子可能也存在于你的电脑中。除了后缀,还有其他例子让人们可以从类内部创建自己的字面量吗?

当我开始设计C++时,我提供了构造函数,它允许你从参数构造某种类型的对象。这非常有效,人们使用构造函数就像它们是字面量一样,但它们不是。这存在运行时成本。

因此,我们在C++11(C++的第一个主要更新标准)中做的第一件事,就是引入了常量表达式作为一个更基础的概念。这是我和我的同事Gabriel Dos Reis合作的工作。

我们有了可以在编译时求值的常量表达式函数,以及常量表达式类型,这样你就可以在编译时进行类型丰富的编程。这在高性能计算和嵌入式系统领域非常重要。这是我们为解决那个问题所做的。常量表达式求值更通用、更易用,是的,也更美观。

在C++11及更早的版本中,你可以写 complex(1, 2) 来创建一个复数。今天,你可以写同样的东西,并让这个复数在编译时创建。因此,假设你想创建一个点(它和复数在结构上类似),你可以写 point(1, 2)。现在你仍然需要写 pointcomplex

C++的另一条思路是泛化并使初始化更安全。因此,如果你知道所需的类型(例如,你有一个返回复数的函数),你可以简单地写 {1, 2},编译器会说:“哦,{1, 2} 应该用来创建一个复数”,然后它就会创建一个复数并返回它。如果这可以在编译时完成,它就会在编译时做。所以这些特性协同工作,你得到了更好的记法、更好的性能。

任何你可以在编译时做的事情,在并发系统中效果更好,因为你无法在一个常量上发生竞态条件——如果它在程序开始前就已经计算好了,你就不会遇到线程问题。


统一初始化与类型安全

所以 {1, 2} 意味着:为我将要放入的这个东西找到一个构造函数。它会查看目标位置,然后判断是否存在一个双参数构造函数、三参数构造函数,或者无论什么。或者如果它只是一个结构体(struct),它会将第一个元素放入第一个成员,对结构体也是如此。哦,我有一段时间没写C++了,这真是个很棒的想法。

这就是统一初始化。如果可能,它会进行初始化。当然,如果存在任何歧义,它会发现歧义。如果你在一个上下文环境中,比如调用一个函数,目标可能是 pointcomplex,编译器会告诉你存在歧义。

实际上,错误检查在C++11中得到了改进,它比C++98更能发现错误。我来自这样一个哲学流派:当你生成代码、构建程序时,编译器是你最好的朋友。要让它成为你最好的朋友,你实际上需要有更多的类型。如果所有东西都是整数,类型系统能帮你什么?它不能。如果所有东西都是浮点数,我无法告诉你它是英制单位还是公制单位,你就会遇到错误。

所以你需要类型丰富的接口,为此你需要能够构建廉价、灵活且方便易用的类型。


演进与简洁表达

因此,我们从 complex(1, 2) 或类似的东西开始工作,到 {1, 2}(大括号是可选的,或仅在需要时使用),最后,我们现在可以写(如果我们想的话)1 + 2i。定义 i 为虚部单位,你甚至不用提 complex 这个词就能得到复数运算。这都隐藏在 i 的定义中。

总结

本节课中,我们一起学习了C++之父Bjarne Stroustrup对C++设计哲学的深刻见解。我们了解到C++的核心使命是服务于基础设施软件——那些要求极高可靠性和性能的系统。为了实现这一目标,C++强调强类型接口编译时计算(如常量表达式)、用户自定义字面量统一初始化等特性,旨在编写出更安全、更高效、更易于维护的代码。同时,我们也认识到,不同的应用领域需要不同的编程语言、工具链乃至思维方式,没有一种“万能”的解决方案。C++的持续演进(如C++11引入的新特性)正是为了在保持其核心优势的同时,让编写正确、高效的代码变得更加简单和直观。

025:跨语言比较面向对象编程方法 🆚

在本节课中,我们将要学习面向对象编程(OOP)在不同编程语言中的实现方式。我们将通过一个简单的“点”(Point)类示例,对比Python、C++、Java、JavaScript、PHP和C#等语言在语法和设计理念上的异同,从而理解OOP核心概念是如何在不同语言环境中演化和表达的。


概述:面向对象编程的演变历程

上一节我们介绍了面向对象的基本思想。本节中,我们来看看几种主流编程语言是如何具体实现这些思想的。我们将从Python的早期实现开始,逐一分析C++、Java、JavaScript、PHP和C#的语法特点。

以下是几种语言及其面向对象特性出现的大致时间线:

  • Python:1991年。它在一个以过程式为主的语言之上实现了面向对象语法。
  • C++:1980年代。最初是作为C语言的预处理器实现的。
  • Java:1995年。深受C和C++影响。
  • JavaScript:1995年。其设计理念更偏向“纯粹”的面向对象。
  • PHP:约2000年(PHP 4/5)。它较晚引入面向对象,并融合了多种语言的特性。
  • C#:2001年。主要借鉴了C++和Java。

Python (1991) 🐍

Python的面向对象实现建立在一个以过程式为主的语言之上。它的一个关键特点是使用 self 参数。

以下是Python中一个“点”类的示例:

class Point:
    def __init__(self, x, y):  # 构造函数
        self.x = x
        self.y = y

    def dump(self):            # 方法
        print(f'x={self.x} y={self.y}')

    def origin(self):          # 方法
        return (self.x ** 2 + self.y ** 2) ** 0.5

核心概念:

  • 构造函数名为 __init__
  • self 是方法的第一个参数,它指向当前实例。这是一个约定,而非语言强制关键字,但被普遍使用。
  • 访问实例变量时,需要使用 self. 前缀,例如 self.x


C++ 🧠

C++最初是作为C语言的预处理器实现的,它将 classpublic 等新语法转换为标准的C代码,再交由C编译器处理。可以看到Python的语法设计受到了C++的启发。

以下是C++中“点”类的示例:

class Point {
  public:
    double x, y;               // 实例变量
    Point(double xc, double yc) { // 构造函数
        x = xc;
        y = yc;
    }
    void dump() {              // 方法
        cout << "x=" << x << " y=" << y << endl;
    }
    double origin() {          // 方法
        return sqrt(x*x + y*y);
    }
};

核心概念:

  • 构造函数名与类名相同(Point)。
  • 实例变量(x, y)在类作用域内是“全局”的,因此在方法内部可以直接使用 xy,无需 selfthis
  • 为了避免命名冲突,构造函数的参数名通常与实例变量名不同(例如用 xc, yc)。
  • 使用 . 语法调用方法:pt.dump()


Java ☕

Java的设计灵感来源于C和C++,并引入了 this 关键字来更清晰地界定实例变量。

以下是Java中“点”类的示例:

public class Point {
    double x, y;                     // 实例变量
    public Point(double x, double y) { // 构造函数
        this.x = x;                  // 使用 this 区分
        this.y = y;
    }
    public void dump() {             // 方法
        System.out.println("x=" + this.x + " y=" + this.y);
    }
    public double origin() {         // 方法
        return Math.sqrt(this.x * this.x + this.y * this.y);
    }
}

核心概念:

  • this 是一个语言内置关键字,指代当前对象实例。
  • 使用 this.x = x; 可以清晰地将参数 x 赋值给实例变量 x,无需改变参数名。
  • 使用 new 运算符显式调用构造函数:Point pt = new Point(4.0, 5.0);


JavaScript 🌐

JavaScript的面向对象设计理念更为“纯粹”,它基于“一等函数”(first-class functions)的概念,早期没有 class 关键字(ES6后引入)。

以下是JavaScript(ES5风格)中“点”类的示例:

function Point(x, y) {               // 构造函数
    this.x = x;
    this.y = y;
    this.dump = function() {         // 方法作为函数赋值给属性
        console.log("x=" + this.x + " y=" + this.y);
    };
    this.origin = function() {       // 方法
        return Math.sqrt(this.x*this.x + this.y*this.y);
    };
}

核心概念:

  • 构造函数就是一个普通函数。
  • 所有属性和方法都在构造函数内部,通过 this. 进行定义和赋值。
  • 方法被定义为匿名函数,并作为值赋给实例的属性(如 this.dump)。此时存储的是函数代码本身,而非执行结果。
  • 同样使用 new 运算符创建实例:var pt = new Point(4.0, 5.0);

PHP 🐘

PHP最初是过程式语言,后来才引入面向对象特性。它借鉴了多种语言的特性,并有一些独特的语法规则。

以下是PHP中“点”类的示例:

class Point {
    public $x, $y;                   // 实例变量以 $ 开头
    public function __construct($x, $y) { // 构造函数
        $this->x = $x;               // 使用 -> 访问成员
        $this->y = $y;
    }
    public function dump() {         // 方法
        echo "x=" . $this->x . " y=" . $this->y . "\n";
    }
    public function origin() {       // 方法
        return sqrt($this->x * $this->x + $this->y * $this->y);
    }
}

核心概念:

  • 变量以 $ 符号开头。
  • 因为点号 . 在PHP中用于字符串连接,所以访问对象成员使用箭头运算符 ->(源自C语言中通过指针访问结构体成员的语法)。
  • 构造函数名为 __construct
  • 在方法内部使用 $this 引用当前实例。
  • 使用 new 创建对象:$p = new Point(4.0, 5.0);,调用方法为 $p->dump();


C# ⚙️

C#出现较晚,充分借鉴了C++和Java的特点,形成了自己的风格。

以下是C#中“点”类的示例:

public class Point {
    double x, y;                     // 实例变量
    public Point(double xc, double yc) { // 构造函数
        x = xc;                      // 直接赋值,无需 this
        y = yc;
    }
    public void Dump() {             // 方法
        Console.WriteLine("x=" + x + " y=" + y);
    }
    public double Origin() {         // 方法
        return Math.Sqrt(x * x + y * y);
    }
}

核心概念:

  • 语法与C++和Java高度相似。
  • 与C++类似,实例变量 xy 在类方法作用域内可直接访问,因此构造函数参数需要不同的名字(如 xc, yc)以避免歧义。
  • 使用 . 语法访问成员:Point p = new Point(4.0, 5.0); p.Dump();


总结与展望

本节课中,我们一起学习了六种不同编程语言(Python、C++、Java、JavaScript、PHP、C#)实现面向对象编程的基本语法。我们看到:

  1. 核心思想一致:都包含类、对象、构造函数、实例变量和方法这些基本概念。
  2. 语法细节各异
    • 引用实例:Python用约定的 self,Java/JS/PHP用 this,C++/C#在类内可直接访问。
    • 成员访问符:大多用 .,PHP因语法冲突改用 ->
    • 对象创建:Java、JS、PHP、C# 使用 new 关键字,Python和C++则更直接。
    • 构造函数命名:Python __init__,PHP __construct,其他语言与类名同。

通过这次跨语言比较,我们可以清晰地看到面向对象编程思想是如何在不同语言生态中演化、相互借鉴并形成各自特色的。理解这些异同,有助于我们更深刻地把握OOP的本质。

在接下来的课程中,我们将尝试在一个非面向对象的语言——C语言中,动手构建我们自己的“对象”模型,这将帮助我们从根本上理解面向对象机制在底层是如何工作的。

026:在C语言中实现类似Python的面向对象模式

在本节课中,我们将学习如何在C语言中模拟Python的面向对象编程模式。C语言本身并不直接支持面向对象,但我们可以通过结构体、函数指针等特性来构建类似的对象系统。我们将通过创建一个Point对象来理解其背后的原理。

上一节我们探讨了Python对象的基本概念,本节中我们来看看如何在C语言中实现类似的功能。

构建C语言中的对象

C语言没有内置的面向对象支持。因此,我们需要通过编写函数、使用结构体和指针等方式来模拟。这实际上是在回答一个问题:Python的面向对象层是如何在C语言结构之上构建的?我们可以想象自己处于Guido van Rossum在1991年构建Python时的位置,思考如何让面向对象的语法在底层的C语言中工作。

以下是Python中一个简单的Point类示例,我们将以此为目标在C中实现:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def dump(self):
        print(f"Object Point at {self}, x={self.x}, y={self.y}")
    def origin(self):
        return (self.x**2 + self.y**2)**0.5

定义结构体

我们首先定义一个结构体来代表Point对象。这个结构体将包含实例变量(数据)和指向方法的函数指针。

以下是Point结构体的定义:

struct Point {
    double x;
    double y;
    void (*del)(struct Point *self);
    void (*dump)(struct Point *self);
    double (*origin)(struct Point *self);
};
  • double x;double y; 是实例变量,用于存储点的坐标。
  • void (*del)(struct Point *self); 是一个指向“析构”函数的指针,用于释放对象内存。
  • void (*dump)(struct Point *self); 是一个指向“打印”函数的指针。
  • double (*origin)(struct Point *self); 是一个指向“计算到原点距离”函数的指针。

这个结构体在内存中占据固定大小(例如,两个double和三个指针,在64位系统上通常是40字节)。C语言的结构体就是一块连续的内存,我们需要预先定义其所有成员。

实现方法函数

接下来,我们实现那些将被结构体中函数指针指向的具体函数。我们遵循一个命名约定,例如为Point类的方法加上point_前缀。

以下是dump方法的实现:

void point_dump(struct Point *self) {
    printf("Object Point at %p, x=%.2f, y=%.2f\n", self, self->x, self->y);
}
  • 函数名是point_dump
  • 第一个参数是struct Point *self,它是指向当前对象实例的指针,类似于Python方法中的self
  • 在函数内部,我们使用self->xself->y来访问实例的变量。->操作符用于通过指针访问结构体成员。

以下是del方法的实现:

void point_del(struct Point *self) {
    free(self);
}
  • 这个函数很简单,就是调用free()来释放创建对象时分配的内存。

以下是origin方法的实现:

double point_origin(struct Point *self) {
    return sqrt(self->x * self->x + self->y * self->y);
}
  • 这个函数计算点到原点的距离并返回一个double值。公式是 √(x² + y²)

实现构造函数

我们需要一个函数来创建并初始化Point对象,这相当于Python中的__init__方法。

以下是构造函数的实现:

struct Point *point_new(double x, double y) {
    struct Point *p = malloc(sizeof(struct Point));
    p->x = x;
    p->y = y;
    p->dump = &point_dump;
    p->origin = &point_origin;
    p->del = &point_del;
    return p;
}
  1. 分配内存:使用malloc分配一块足以容纳struct Point的内存。
  2. 设置数据:将传入的xy参数赋值给新对象的xy成员。
  3. 绑定方法:将对象的函数指针成员(dump, origin, del)指向我们之前定义的全局函数(point_dump, point_origin, point_del)。这里使用了&取地址操作符。
  4. 返回对象:最后返回指向新创建对象的指针。

本质上,构造函数分配了一块内存,并用数据和函数地址填充它,从而“组装”出一个可用的对象。

使用对象

main函数中,我们可以像下面这样使用我们创建的Point对象:

int main() {
    // 创建对象
    struct Point *pt = point_new(4.0, 5.0);

    // 调用方法
    pt->dump(pt); // 注意:需要将‘pt’作为参数传入
    double dist = pt->origin(pt);
    printf("Distance to origin: %.2f\n", dist);

    // 清理对象
    pt->del(pt);

    return 0;
}
  • struct Point *pt = point_new(4.0, 5.0);:这行代码看起来很像面向对象语言中的new操作,但实际上它只是调用了一个全局函数point_new
  • pt->dump(pt);:这行代码做了两件事:
    1. pt->dump通过指针pt找到结构体中的dump成员,该成员是一个函数指针。
    2. (pt)则是调用该函数,并将pt(即self)作为参数传入。这是模拟面向对象调用的关键:方法函数总是将对象实例作为第一个参数
  • pt->del(pt);:最后调用析构函数来释放内存。

总结与展望

本节课中我们一起学习了如何在C语言中模拟面向对象编程。我们通过以下步骤实现了一个简单的Point对象:

  1. 定义一个包含数据和函数指针的结构体。
  2. 实现一系列全局函数作为“方法”,这些函数的第一个参数都是指向对象实例的指针(self)。
  3. 实现一个“构造函数”来分配内存并初始化结构体,将函数指针指向对应的全局函数。
  4. 通过结构体指针和函数指针的配合来调用“方法”。

从本质上讲,这里并没有真正的“对象”,只有结构体、指针和函数。我们利用函数指针和命名约定模仿了面向对象的行为。早期的Python和C++(最初作为C的预处理器)在底层很可能采用了类似的技术来实现其面向对象特性。

下一节,我们将尝试实现一个简化版的Python字符串类,进一步巩固这种在C语言中构建对象模型的思想。

027:在C语言中实现Python字符串(str)类 📝

在本节课中,我们将学习如何在C语言中模拟Python的字符串类。我们将创建一个名为PyString的C语言结构体,并为其实现构造函数、析构函数以及追加字符、追加字符串、赋值和获取长度等方法。通过这个过程,你将理解面向对象编程中封装和内存管理的基本概念。


从点类到字符串类 🔄

上一节我们介绍了一个简单的点类,它只包含两个double类型的坐标。本节中,我们将转向一个更复杂的实际应用:字符串类。

Python字符串类的有趣之处在于它可以动态扩展。我们一直在讨论指针和数组,即使在C语言中调用malloc,也无法简单地扩展已分配的内存。而在Python中,我们可以轻松地创建字符串,向其追加字符,打印它,再追加更多内容,然后将其赋值给另一个变量并获取其长度。在整个过程中,我们无需手动分配或释放任何内存。

当你查看完我们将要编写的代码后,可能会想:“我很庆幸我是在用Python编程,很高兴Guido van Rossum给了我一个可扩展的字符串类,而不是一个固定长度的字符数组。”

因此,我们将使用C语言,并遵循我们的小约定,创建一个名为PyString的字符串类。


模拟Python语法 🎯

如果我们查看代码,我们的目标是基本上在C语言中模拟Python的语法。

我们将从创建一个结构体PyString开始,获取一个指向它的指针,将其命名为x。我们将调用构造函数PyString_new,然后使用一个小的dump函数来打印其状态。接着,我们将向其追加一个字符H,再次打印状态。然后,我们将追加一个完整的字符串LO world。在C语言中,H是一个字符,而LO world是一个多字符字符串。之后,我们将把它赋值给一个全新的字符串,并像Python一样打印它,即获取这个对象的字符串表示形式,或者获取它的长度。最后,我们将删除它,将其丢弃。

你可以看到,所有的Python操作都在某种程度上被模仿了,只是使用了C语言的命名约定。


封装与内存管理 🛡️

这里需要注意的一点是,在主代码中,我们从未分配或释放任何内存。这些操作都在对象内部完成。在对象内部,我们有责任正确地分配和释放内存。

但这里一个有趣的事情是,我还没有向你展示执行这些操作的代码。这没关系,因为只要我们执行new操作,使用它,然后执行delete操作,我们就可以用它来做事情。在PyString内部,它为我们处理了所有的内存管理,这就是面向对象方法的美妙之处之一。

同样,C语言一方的语法相当繁琐,而Python一方的语法则相当轻量。但关键在于,在Python中,我们从来不用担心字符串太长或太短,或者发生缓冲区溢出等问题。

因此,当我们深入探讨时,我们必须认识到,这个PyString对象的部分工作就是代表我们处理所有的内存分配,这样我们作为程序员就可以编写更简单的代码。


构建PyString类 🏗️

现在,我们将构建PyString类。我们将创建一个名为PyString的结构体,其中包含三样东西:

  • 字符串的长度 (len)
  • 我们为字符串分配了多少数据 (alloc)
  • 实际的字符数组 (data)

以下是其结构定义:

struct PyString {
    int len;
    int alloc;
    char *data;
};

我们必须在其内部有一个字符数组。我们不会让外部代码直接接触这个字符数组。我们将完全在这个对象内部管理它。我们为自己画了一个小气泡,意思是:你可以使用我的对象,但我会为你处理一切。所以不要碰我的内部东西。

因此,我们会认为PyString结构体中的所有内容在概念上都是私有的。在C语言中,我们没有很好的方法来强制其私有化,但在面向对象的概念中,lenallocdata会被认为是私有的。


构造函数与析构函数 ⚙️

在构造函数中,我们被要求创建一个新的Python字符串,完成后我们将返回一个指向该结构的指针。

我们首先要做的是分配内存。int通常是32位,所以是4字节。alloc也是4字节,data是一个指针,在64位系统上可能是8字节。所以PyString结构体的大小可能是16字节。当我们执行malloc(sizeof(struct PyString))时,我们得到了16字节。

关键点在于,这并没有分配实际的字符串数据。它只是分配了8字节(一个指针)来指向字符串数据。*data是一个指针,第一次malloc只给了我们这个指针,而不是实际的数据。

然后我们进行设置。我们将字符串的长度len设为0,表示其中没有任何内容。我们为底层数据字符串分配的长度alloc设为10。接着,我们立即调用malloc来获取10个字符。现在,data就是一个10个字符的数组。alloc告诉我们分配了多少,因为我们的工作就是在这个对象内部跟踪这些东西。

然后,为了确保良好初始化,我们在已分配字符数组的零位置(即第一个字符)放上\0。我们不知道其余字符是什么,只知道第一个是\0。最后,我们返回指向结构体的指针,而不是指向数据的指针。

这个构造函数在主函数中被调用:struct PyString *x = PyString_new();。完成后,我们就得到了这个很酷的小数据块,它已经被动态分配,并准备好让我们用它来做一些很酷的事情。

我们有了结构体,有了构造函数,然后我们还有析构函数PyString_delete。它再次传入self指针。现在,我们调用free。回想一下,有两样东西被分配了:一个是数据,即我们拥有的字符数组,我们必须释放它;然后我们必须释放对象本身。

delete函数的最后,我们已经归还了所有分配的数据。这里重要的一点是,这两条语句的顺序非常重要。当我们释放self后,我们不应该再访问self。虽然可能还有一些数据留在那里没有被破坏,但你无法确定。这就是为什么我们必须在释放self之前先释放self->data,因为以相反的顺序做是错误的。


访问器方法:dump、len和_str 📤

接下来是PyString_dump函数。在其中,我们打印出当前的长度、到目前为止分配了多少空间以及其中目前包含的数据。

然后是PyString_len函数。它返回一个整数,并将self作为参数。关键点在于它返回self->len。你可能会问,为什么我们不让调用代码直接访问self->len?这又涉及到封装。我们不想透露我们在这个变量中跟踪长度的事实,因为我们不希望调用代码去修改它。记住,lendataalloc在概念上是私有的。

因此,我们不说“直接去看self->len”,而是说“请调用我的函数,我会给你想要的东西”。你只需调用len函数并传入实例,这允许我更改len的名称,允许我以不同的方式解释长度,允许我做各种各样的事情。但至少,对象的编写者控制着与外部世界的契约。因此,通过隐藏所有数据并提供方法来访问这些数据(我们称这些方法为访问器),是一个好主意。

接下来是_str方法。如果你想到Python,你可以说str(任何东西),它会将其转换为字符串。我们碰巧一直将self->data维护为一个有效的字符串。所以当你说“把这个字符串对象转换成可以打印的字符串”时,我只需返回我们内部一直维护的字符串的指针。


核心方法:append、append_s和assign ✍️

我们还需要添加一些其他方法。我们需要添加一个append方法来添加单个字符。你可以看到它有两个参数:self和一个单个字符c(例如H)。我们还有append_s方法,它也有两个参数:实例self和一个指向整个字符字符串的指针。然后我们有一个assign方法,它也有两个参数:一个是self,一个是指向字符字符串的指针。

我不会直接给你这些代码行,而是给你一个任务去编写这些代码。我会告诉你它们应该如何工作,但不会给你代码。我告诉你,PyString_append大约有10行代码,PyString_append_s只有一行代码(它是一个for循环),PyString_assign大约有三行代码。PyString_append_s会调用PyString_append,而PyString_assign会调用PyString_append_s。所以我们在这里做了很多重用。

让我们看看这些方法将如何在我们的主程序中使用。我们说struct PyString *x = PyString_new();,意思是“给我一个新的字符串对象”。然后我们将向其追加一个单个字符H,接着追加一个多字符字符串,每次操作后都打印状态。然后,我们将用一个全新的字符串覆盖我们的对象。


实现append方法的逻辑 🤔

现在,让我们逐步了解你可能需要在PyString_append中做什么。回想一下,当我们设置这个东西时,我们创建了len,分配了10个字符的数组,让data指向那个10字符数组,并记住我们有10个字符的分配空间。

append做的第一件事是检查len是否大于我们已分配的空间(alloc)。这意味着,如果我们打算放入字符,比如字母H,我们是否可以简单地追加它然后更新len?我们仍然分配了10个字符,并且只使用了其中一个。所以我们可以直接开始向data中追加,对吧?并且我们必须在它后面放入\0,这样data就始终是一个有效的字符串。

想象一下,我们创建了新对象。它有一个长度为0,一个10字符的数组,并且在第一个字符处有一个字符串结束符\0。我们很好,我们分配了10个,并且我们知道我们分配了10个。然后,如果我们添加一个H字符,一个单个字符H,我们需要做的就是将H添加到那个数组中(在那种情况下是data[0]),然后将len更新为1,然后说data[1]\0,这样我们就正确地终止了它。

所以,在第一行之后,dataH,它是一个有效的H字符串。我们追加了一个单个字符,更新了长度,然后终止了字符串。然后我们转到下一行,看到在这种情况下,我们将追加字母E。我们查看它的长度,因为它告诉我们应该放在哪里。长度是1,所以我们把它放在下标1的位置,并添加\0

然后我们检查以确保我们有空间容纳它,因为我们有10个,但我们只用了两个。实际上我们用了三个,因为HE和字符串结束符。所以我们实际上用了3个,但我们拥有的字符串长度是2。因此,只要没有人要求我们追加超过10个字符,append就是一个相当简单的操作。你只需添加到我们已经分配好的字符数组中。


处理空间不足:realloc 🔄

当然,情况会变得有趣。你可以追加HELLO、空格、WOR。在这一点上,我们有了9个字符。data中字符串的长度是9。我们已经正确地终止了它,所以我们用了第10个字符来终止字符串,所以我们真的很好,一切都很棒。

但现在的问题是,我们必须在R后面追加L。所以我们必须做的是,我们必须在构造函数中调用malloc,而现在在append中,我们将不得不调用realloc来说:“哦,我原来要了10个字符,但现在我想把它从10个扩展到20个字符。”realloc就是做这个的。

realloc说:“这是一个指针,它知道它有多少个字符,请重新分配这个指针。获取这个指针指向的数据,并给我,把它变成20长而不是10长。它可能必须复制数据。”让我们看看realloc做了什么。我们可以通过使用指向当前区域的指针和新的大小调用realloc来扩展动态分配区域的大小。

所以在构造函数中,你看到我们malloc了10个。然后,在append中,我们说,如果len大于self->alloc - 2(即我们没有剩下两个字符的空间),那么我们将不得不realloc

我们要做的是把它从10个字符改为20个字符。所以我们将把self->alloc(它是10)加上10。所以现在self->alloc是20,然后我们将设置self->datarealloc旧的self->data(大小为20个字符)的结果。

这个realloc接受一个指针和一个新的大小,并返回一个新指针。实际上,它可能必须在内存中移动数据。所以你不能假设self->data在调用前后是相同的。但你可以假设,如果它不得不移动数据以便在其空闲空间中找到20个字符的位置,它会复制所有前10个字符,然后你会得到一个新的指针。这就是为什么你看到self->data既出现在调用realloc的参数中,也出现在赋值语句的左侧。

我们回到这里,可以看到,哦,是的,现在我们有了20个字符,它有足够的空间容纳LD\0


测试我们的类 🧪

现在,我们将展示基本上用来测试我们类的代码。我们将创建一个新的PyString对象,打印它,追加一个单个字符H,再打印它,追加一个字符串。让这变得简单的一种方法是,让append_s为9个字符重复调用append,因为追加一个9字符的字符串与追加9次单个字符是相同的。然后是赋值assign

赋值一个全新的字符串,这意味着你必须将len重置,设置一些东西,然后检查大小并做一大堆事情。

然后,我们将要求PyString_str返回一个可打印的字符串。接着,我们将要求PyString_len告诉我们这个东西有多长。

所以你需要编写一些代码,可能不需要太多代码,大约15行。但这是你需要深入思考的代码,你需要理解结构体,需要理解指针等等。


总结 📚

本节课中,我们一起学习了如何在C语言中模拟Python的字符串类。我们创建了一个PyString结构体,实现了构造函数来分配初始内存,析构函数来正确释放所有内存。我们探讨了append方法如何检查并管理内存,在空间不足时使用realloc进行扩展。我们还介绍了访问器方法lenstr,它们封装了内部数据,提供了安全的访问接口。最后,我们了解了如何通过append_sassign方法重用append的功能来构建更复杂的操作。这个过程展示了面向对象编程中封装和内存管理的基本思想,即使是在C这样的非面向对象语言中。

028:在C语言中实现Python列表(list)类 📝

在本节课中,我们将学习如何在C语言中模拟实现Python的列表(list)类。我们将从零开始构建一个链表结构,并为其实现创建、添加、查找、打印和销毁等核心功能。通过这个过程,你将深入理解面向对象思想在C语言中的应用以及动态内存管理的细节。

从Python到C:列表功能对比 🔄

上一节我们探讨了字符串对象,本节我们来看看如何构建一个列表对象。首先,对比一下Python列表和我们将要在C中实现的列表的基本操作。

在Python中,我们这样使用列表:

lst = []
lst.append("hello world")
print(lst)
lst.append("catchphrase")
print(lst)
lst.append("Brian")
print(lst)
print(len(lst))
print(lst.index("Brian"))
if "Bob" in lst:
    print(lst.index("Bob"))
else:
    print("Bob not found")

而在我们的C实现中,对应的操作将如下所示:

PList_t *lst = pylist_new();
pylist_append(lst, "hello world");
pylist_print(lst);
pylist_append(lst, "catchphrase");
pylist_print(lst);
pylist_append(lst, "Brian");
pylist_print(lst);
printf("%d\n", pylist_len(lst));
printf("%d\n", pylist_index(lst, "Brian"));
printf("%d\n", pylist_index(lst, "Bob")); // 返回-1表示未找到
pylist_del(lst);

主要区别在于,在C版本中,我们通过函数调用(如 pylist_append)来操作列表对象,并且需要手动管理内存(最后调用 pylist_del)。此外,查找操作 pylist_index 在未找到元素时会返回 -1,而不是像Python那样抛出异常。

构建列表对象:数据结构设计 🏗️

现在,我们从列表的使用者转变为构建者。我们的任务是设计数据结构并动态分配所需内存,将实现细节封装在对象内部,这正是面向对象编程的重要思想。

以下是实现所需的核心数据结构。

首先,定义链表中的节点结构 Lnode_t

typedef struct Lnode {
    char *text;          // 指向存储字符串的指针
    struct Lnode *next;  // 指向下一个节点的指针
} Lnode_t;
  • text: 指向动态分配的字符串。
  • next: 指向链表中下一个节点的指针。这是一个约定俗成的命名。

接着,定义列表本身的结构 PList_t,它管理整个链表:

typedef struct PList {
    Lnode_t *head; // 指向链表第一个节点的指针
    Lnode_t *tail; // 指向链表最后一个节点的指针
    int count;     // 记录列表中元素的数量
} PList_t;
  • head: 指向链表头节点。
  • tail: 指向链表尾节点,便于在末尾快速添加元素。
  • count: 记录当前列表中的元素个数。

核心方法实现 ⚙️

了解了数据结构后,我们来看看如何实现列表的核心操作方法。

创建与销毁列表

pylist_new 函数负责创建一个新的空列表:

PList_t *pylist_new(void) {
    PList_t *p = (PList_t *)malloc(sizeof(PList_t));
    p->head = NULL;
    p->tail = NULL;
    p->count = 0;
    return p;
}

该函数分配 PList_t 结构所需的内存(约20字节),并将 headtail 初始化为 NULLcount 初始化为0。

pylist_del 函数则负责安全地销毁列表并释放所有内存。这比创建要复杂,因为我们需要按正确顺序释放所有嵌套分配的内存。

释放动态内存的顺序至关重要,必须从“树叶”(最内层的数据)向“树根”(外层结构)进行。以下是 pylist_del 的逻辑步骤:

  1. head 开始遍历链表。
  2. 对于每个节点 (cur):
    a. 先释放该节点存储的字符串 (cur->text)。
    b. 保存下一个节点的地址 (next = cur->next)。
    c. 释放当前节点本身 (free(cur))。
    d. 将 cur 移动到下一个节点 (cur = next)。
  3. 循环结束后,所有节点和字符串都已释放,最后释放列表结构本身 (free(self))。

对于一个包含三个节点(字符串分别为"C"、"is"、"fun")的列表,释放顺序将是:字符串"C" -> 第一个节点 -> 字符串"is" -> 第二个节点 -> 字符串"fun" -> 第三个节点 -> 列表结构本身。

实现Python风格的打印输出

接下来,我们实现 pylist_print 函数,目标是让输出格式与Python列表完全一致,例如:['hello world', 'catchphrase', 'Brian']

具体要求如下:

  • 以左方括号 [ 开始。
  • 每个字符串用单引号 ' 包围。
  • 元素间用逗号和空格 , 分隔。
  • 以右方括号 ] 结束。

实现提示: 在C语言中,不要尝试拼接字符串再来输出,因为我们无法预知字符串的长度。更有效的方法是直接使用 printf 在循环中逐步输出。记住,printf 不会自动添加换行符,除非你明确使用 \n。你可以通过组合使用 printf("[")printf("'%s'", cur->text)printf(", ") 等语句来实现目标格式。这大约需要10行代码。

其他实用方法

此外,我们还需要实现其他几个方法:

  • pylist_len: 返回列表长度。这个方法非常简单,直接返回 PList_t 结构中的 count 字段即可。
  • pylist_index: 查找给定字符串在列表中的位置(索引)。实现方式是通过循环遍历链表,将每个节点的 text 与目标字符串进行比较(使用 strcmp)。如果找到,则返回当前计数(从0开始);如果遍历完仍未找到,则返回 -1
  • pylist_append: 在列表末尾添加一个新元素。这个操作涉及创建新节点、分配字符串内存、更新链表尾指针和计数器。如果你已经学习过第6章关于链表的內容,应该对此很熟悉。

功能测试与总结 🎯

最后,让我们用一段测试代码来验证我们实现的列表类的功能,它直接对应本节开头展示的Python代码逻辑:

PList_t *lst = pylist_new();
pylist_append(lst, "hello world");
pylist_print(lst);
pylist_append(lst, "catchphrase");
pylist_print(lst);
pylist_append(lst, "Brian");
pylist_print(lst);
printf("%d\n", pylist_len(lst));
printf("%d\n", pylist_index(lst, "Brian"));
printf("%d\n", pylist_index(lst, "Bob"));
pylist_del(lst);

运行这段代码,其输出将与Python版本高度相似(除了将“Bob not found”替换为索引值 -1)。这表明我们已经成功在C语言中构建了一个模拟Python列表行为的对象。

本节课总结
我们一起学习了如何在C语言中模拟实现Python的列表类。我们设计了 Lnode_tPList_t 数据结构来构建链表,并实现了 newdelappendindexlenprint 等核心方法。重点掌握了面向对象封装的思想以及动态内存分配与释放的正确顺序。通过这个练习,你实际上走过了Python创始人Guido van Rossum在构建列表对象时类似的思考路径。

接下来,我们将运用这些知识,挑战实现更复杂的数据结构——字典(dictionary)。

029:在C语言中实现Python字典(dict)类 🗂️

在本节课中,我们将学习如何使用C语言的基础特性——结构体、指针和动态内存管理——来构建一个类似于Python字典(dict)的简单对象。我们将从零开始,实现字典的创建、添加、查找、更新和删除等核心功能。


概述

Python的字典是一种非常方便的数据结构,它允许我们通过“键”来存储和检索对应的“值”。在C语言中,虽然没有内置的字典类型,但我们可以利用结构体和链表的概念来模拟实现它。本节教程将引导你完成这一过程,你将看到一个高级数据结构是如何在底层用更基础的构件搭建起来的。

上一节我们介绍了链表(list)的实现,本节中我们来看看如何扩展链表的概念,使其能够存储键值对,从而构建一个字典。


字典的基本设计与结构

我们的C语言字典将基于链表实现。链表中的每个节点不再只存储一个值,而是存储一个键(key)和一个值(value)。字典本身则是一个管理这些节点的结构体。

以下是核心的结构体定义:

// 字典节点结构体
typedef struct DNode {
    char *key;           // 指向键字符串的指针
    char *value;         // 指向值字符串的指针
    struct DNode *next;  // 指向下一个节点的指针
} DNode;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/b256fa6f0fef9837b4b46376a8d3c300_4.png)

// 字典结构体
typedef struct {
    DNode *head;  // 指向链表头节点的指针
    DNode *tail;  // 指向链表尾节点的指针
    int count;    // 字典中键值对的数量
} Dictionary;

可以看到,Dictionary 结构体与之前实现的链表(PList)非常相似,都包含头指针、尾指针和计数器。主要的区别在于节点(DNode)内部,它包含了两个字符串指针:keyvalue


核心方法的实现

我们将为字典实现一系列方法,包括创建、销毁、添加/更新、查找、获取长度和打印等。

1. 创建与销毁字典

创建字典(new)和销毁字典(delete)的逻辑与链表几乎一致。

  • Dictionary* Dict_new(): 分配内存给一个Dictionary结构体,初始化其headtailNULLcount为0。
  • void Dict_delete(Dictionary* self): 遍历整个链表,释放每个节点中keyvalue指针所指向的内存,然后释放节点本身,最后释放字典结构体。

以下是销毁方法的伪代码逻辑:

void Dict_delete(Dictionary* self) {
    DNode* current = self->head;
    while (current != NULL) {
        DNode* next = current->next; // 预先保存下一个节点
        free(current->key);          // 释放键字符串
        free(current->value);        // 释放值字符串
        free(current);               // 释放节点本身
        current = next;              // 移动到下一个节点
    }
    free(self); // 释放字典结构体
}

2. 查找节点

实现一个内部的find方法至关重要,它将被put(添加/更新)和get(获取)方法复用。

  • DNode* Dict_find(Dictionary* self, const char* key): 遍历链表,比较每个节点的key与传入的key是否相同(使用strcmp函数)。如果找到,则返回指向该节点的指针;如果遍历完仍未找到,则返回NULL

3. 添加或更新键值对 (put)

put方法用于向字典中添加新的键值对,或者更新已存在键对应的值。

以下是put方法的核心步骤:

  1. 调用Dict_find查找键是否已存在。
  2. 如果找到(old != NULL),说明是更新操作:
    • 释放旧value指向的内存。
    • 为新值字符串分配内存(malloc)并复制内容。
    • 将节点的value指针指向这块新内存。
  3. 如果没找到(old == NULL),说明是添加操作:
    • 创建一个新的DNode节点。
    • keyvalue字符串分别分配内存并复制内容。
    • 将新节点添加到链表末尾(操作与链表追加节点相同)。
    • 更新字典的count

4. 获取值 (get)

get方法根据给定的键查找并返回对应的值。

  • char* Dict_get(Dictionary* self, const char* key): 调用Dict_find方法查找节点。如果找到节点,则返回其value;如果未找到,可以返回一个默认值(如NULL或特定的错误码)。

5. 其他工具方法

  • int Dict_len(Dictionary* self): 直接返回字典结构体中的count成员。
  • void Dict_print(Dictionary* self): 遍历链表,按照{‘key’: ‘value’}的格式打印出所有键值对,以匹配Python字典的输出样式。

内存管理详解

由于keyvalue都是指向字符串的指针,我们必须仔细管理它们的内存生命周期。这是与简单链表最大的不同之处。

关键规则是:

  1. 分配时复制:在put方法中,当接收到一个字符串(如”Z””catch phrase”)时,我们不能直接保存传入的指针,因为它的生命周期可能很短(例如,是一个临时变量)。我们必须使用malloc分配一块新的内存,并使用strcpy将字符串内容复制进去,然后保存这个新内存的地址。
  2. 更新时先释放:当更新一个已存在键的值时,在将value指针指向新的内存块之前,必须先用free释放它原来指向的旧内存块,否则会造成内存泄漏。
  3. 销毁时全部释放:在Dict_delete中,除了释放节点本身,还必须依次释放每个节点中keyvalue指针所指向的内存。

让我们通过一个例子可视化这个过程:

  1. 执行 Dict_put(dict, “Z”, “catch phrase”):

    • 系统为DNode、字符串”Z”、字符串”catch phrase”分别分配三块内存。
    • DNodekeyvalue指针分别指向后两块内存。
    • 字典的headtail指向这个新节点。
  2. 接着执行 Dict_put(dict, “Z”, “W”):

    • Dict_find找到了键为”Z”的节点。
    • 系统释放”catch phrase”所占用的内存。
    • 系统为字符串”W”分配新内存并复制内容。
    • 将该节点的value指针改为指向存储”W”的新内存。

从链表到字典的演进

本质上,我们实现的字典就是一个每个节点携带两个数据的链表。我们没有使用更高级的数据结构(如哈希表或二叉搜索树)来优化查找速度,因此在键值对数量很大时,find操作的效率会较低(时间复杂度为O(n))。

然而,这个实现完美地演示了对象封装的思想:

  • 隐藏复杂性:用户(调用者)只需要知道Dict_new, Dict_put, Dict_get等接口,完全不用关心内部是基于链表实现的,也无需处理繁琐的mallocfree
  • 构建抽象:我们利用C语言的基础功能,构建了一个更高级、更易用的数据抽象。这正是Python等高级语言在底层所做的事情的简化版。

可以想象,Python的创始人Guido van Rossum在早期构建Python时,很可能也编写过类似的简单版字符串、列表和字典类,然后再用更高效的算法和内存管理策略去优化它们。


总结

本节课中我们一起学习了如何在C语言中实现一个简单的Python风格字典(dict)。我们主要完成了以下工作:

  1. 设计结构:定义了DictionaryDNode结构体,作为字典的骨架。
  2. 实现核心操作:逐步实现了字典的创建(new)、销毁(delete)、添加/更新(put)、查找(get)等方法。
  3. 深入内存管理:重点理解了为何以及如何为键和值动态分配和释放内存,这是C语言实现此类数据结构的核心。
  4. 理解抽象意义:认识到这个练习不仅是在实现一个功能,更是在学习如何将底层细节(指针、内存)封装起来,向上提供简洁、安全的接口,这是面向对象编程和构建高级语言的基础。

通过这个从链表到字典的构建过程,你应该对“如何使用基础工具构建复杂工具”有了更深刻的理解。虽然我们的实现效率不高,但它清晰地揭示了数据结构的本质和软件抽象的威力。

030:在C语言中实现封装和接口 🧱

在本节课中,我们将学习面向对象编程的核心原则之一:封装。我们将探讨如何将数据和方法捆绑在一起,并定义清晰的接口,从而在C语言中隐藏实现细节,构建更健壮、更易维护的代码。


从命名约定到结构内方法

上一节我们使用带前缀和下划线的函数名(如 Pidict_put)来模拟对象方法。本节中,我们来看看如何将这些方法直接放入结构体内部,以实现更好的封装。

我们将创建一个名为 Pidict 的结构体,它不再仅仅包含数据(如链表的头尾指针),还将包含指向各个操作函数的指针。

以下是实现封装后的结构体定义示例:

typedef struct Pidict {
    struct Node *head;
    struct Node *tail;
    void (*put)(struct Pidict *self, char *key, char *value);
    int (*len)(struct Pidict *self);
    char* (*get)(struct Pidict *self, char *key);
    void (*del)(struct Pidict *self, char *key);
} Pidict;

通过这种方式,所有与 Pidict 对象相关的操作都成为了结构体的一部分。


为何要避免“泄漏的抽象”

当我们直接在调用代码中访问或依赖对象内部的实现细节(例如,遍历链表时直接使用 head 指针),我们就破坏了抽象边界。这被称为“泄漏的抽象”。

以下是一个“泄漏”的例子,它暴露了底层是链表的事实:

// 泄漏的抽象:调用者需要知道内部是链表
for (struct Node *cur = dict->head; cur != NULL; cur = cur->next) {
    printf("%s: %s\n", cur->key, cur->value);
}

如果未来我们将底层实现从链表改为哈希表或树,所有这样的调用代码都需要修改。封装的目的就是通过一个稳定的接口来防止这种情况。


访问控制:公共 vs. 私有

为了实现封装,我们需要区分哪些部分是对外公开的(公共接口),哪些是内部隐藏的(私有实现)。

  • 公共(Public):调用代码可以访问的数据和方法,构成了对象的稳定契约。
  • 私有(Private):仅供对象内部使用,调用代码不应直接访问。

不同语言有不同的语法来标记访问控制:

  • JavaC++ 中,使用 privatepublic 关键字。
    // Java示例
    public class Point {
        private double x; // 私有成员
        private double y;
        public Point(double x, double y) { ... } // 公共构造方法
        public void dump() { ... } // 公共方法
    }
    
  • Python 中,约定使用双下划线 __ 作为前缀来指示私有成员(名称修饰)。
    # Python示例
    class Point:
        def __init__(self, x, y):
            self.__x = x  # 私有属性
            self.__y = y
        def dump(self):   # 公共方法
            print(self.__x, self.__y)
    

在我们的C语言实现中,我们将通过将函数指针放入结构体,并仅通过这些指针来调用方法,从而在逻辑上实现“公共接口”。而像 headtail 这样的数据成员,则应被视为“私有”,调用者不应直接操作。


定义清晰的接口

接口是对象与外部世界之间的契约。它规定了“做什么”,而隐藏了“怎么做”。通过将方法指针集成到结构体中,我们就在C语言中定义了一个清晰的接口。

调用方式将从:

Pidict_put(dict, "z", "catchphrase");

变为更面向对象的形式:

dict->put(dict, "z", "catchphrase");
// 注意:第一个 `dict` 参数是“self”,模拟了Python中的实例方法调用。

这种方式将所有相关操作都绑定到了对象本身,使得API更加一致和直观,避免了像PHP中某些字符串函数那样参数顺序不一致的问题。


总结

本节课中我们一起学习了如何在C语言中应用封装原则。

  1. 我们探讨了将方法从全局命名空间移入结构体内部的好处。
  2. 我们理解了“泄漏的抽象”概念及其危害,它会导致代码紧密耦合于特定实现。
  3. 我们介绍了公共接口与私有实现的区别,这是封装的核心。
  4. 我们看到了如何通过结构体内的函数指针,在C语言中定义一个清晰、稳定的对象接口。

通过实现封装,我们为代码奠定了坚实的基础,使其更模块化、更易于维护,并为未来实现不同的底层数据结构(如哈希表)铺平了道路,而无需修改上层的调用代码。

031:探索跨语言的映射抽象 🗺️

在本节课中,我们将深入探讨“抽象”的概念。我们将以一个接口为例,比较它在多种不同编程语言中的实现。我们将这种抽象称为“映射”(Map)。映射是一个通用术语,用于抽象地描述键值对集合。每种语言对其都有不同的命名。我们将学习映射的基本操作,并了解“迭代器模式”作为一种用于遍历多种实现的抽象方法。

映射:一个跨语言的抽象概念

上一节我们介绍了抽象的概念,本节中我们来看看一个具体的抽象:映射。映射是一种常见的数据结构,用于存储键值对。尽管核心概念相同,但不同编程语言对其命名和实现方式各有不同。

以下是几种语言中映射的名称:

  • C++: 直接称之为 map
  • Python: 称之为 dictionary(字典)。
  • Java: 也称之为 Map(注意首字母大写)。
  • PHP: 称之为 array(数组)。
  • JavaScript: 实际上使用 Object(对象)来实现类似功能。

迭代器模式:遍历的抽象

为了遍历映射中的元素,我们使用迭代器模式。迭代器是一个对象,它提供了一种顺序访问集合元素的方法,而无需暴露其底层表示。其核心思想是避免一次性复制所有数据,而是通过一个“指针”逐步推进。

迭代器的基本操作通常遵循以下模式:

# 伪代码示例
iterator = collection.get_iterator()
while iterator.has_next():
    item = iterator.next()
    # 处理 item

各语言中的映射与迭代示例

现在,让我们通过具体代码,看看上述概念在几种流行语言中是如何实现的。

Python 示例

在Python中,映射通过字典实现。以下是创建字典、添加元素、访问元素以及使用迭代器遍历的示例。

# 创建字典
d = {}
# 添加键值对,重复键会覆盖旧值
d[‘z‘] = 8
d[‘z‘] = 1  # 覆盖,最终 d[‘z‘] 为 1
d[‘x‘] = 9
d[‘b‘] = 3
d[‘a‘] = 4

# 打印字典
print(d)

# 使用 get 方法安全访问,键不存在时返回默认值 42
value_z = d.get(‘z‘, 42)
value_x = d.get(‘x‘, 42)  # ‘x‘ 不存在,返回 42
print(f“z = {value_z}, x = {value_x}“)

# 获取字典项目的迭代器
items_iter = d.items()
# 使用 next() 函数手动迭代
entry = next(items_iter, False)
while entry:
    print(entry)
    entry = next(items_iter, False)

代码说明d.items() 返回一个视图对象(迭代器),next() 函数用于从中获取下一个键值对。当没有更多元素时,返回我们指定的默认值 False,循环终止。

PHP 示例

在PHP中,数组(array)可以充当映射。以下是等效操作。

// 创建数组(作为映射使用)
$a = array();
// 添加元素,重复键会覆盖
$a[‘z‘] = 8;
$a[‘z‘] = 1; // 覆盖
$a[‘x‘] = 9;
$a[‘b‘] = 3;
$a[‘a‘] = 4;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/6b0334a926013101f9937f8bd835b793_16.png)

// 打印数组
print_r($a);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/6b0334a926013101f9937f8bd835b793_18.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/6b0334a926013101f9937f8bd835b793_19.png)

// 使用 null 合并运算符 ?? 进行安全访问(PHP 7+)
$value_z = $a[‘z‘] ?? 42;
$value_x = $a[‘x‘] ?? 42; // ‘x‘ 不存在,返回 42
echo “z = $value_z, x = $value_x\n“;

// 使用 foreach 循环迭代(PHP内置的迭代抽象)
foreach ($a as $key => $value) {
    echo “$key => $value\n“;
}

代码说明:PHP 的 foreach 结构内部实现了迭代器模式,开发者无需直接操作迭代器对象即可遍历数组。

C++ 示例

C++ 使用 std::map,并且是强类型语言,需要指定键和值的类型。

#include <iostream>
#include <map>
#include <string>

int main() {
    // 声明一个映射,键为string,值为int
    std::map<std::string, int> mp;

    // 插入元素,使用下标操作符,重复键会覆盖
    mp[“z“] = 8;
    mp[“z“] = 1; // 覆盖
    mp[“y“] = 2;
    mp[“b“] = 3;
    mp[“a“] = 4;

    // 模拟 get 操作:检查键是否存在
    // count(‘z‘) 返回键 ‘z‘ 出现的次数(0或1)
    int value_z = (mp.count(“z“) > 0) ? mp[“z“] : 42;
    int value_x = (mp.count(“x“) > 0) ? mp[“x“] : 42; // ‘x‘ 不存在,返回 42
    std::cout << “z = “ << value_z << “, x = “ << value_x << std::endl;

    // 使用迭代器遍历
    // auto 关键字自动推导迭代器类型
    for (auto cur = mp.begin(); cur != mp.end(); ++cur) {
        // cur->first 是键,cur->second 是值
        std::cout << cur->first << “ => “ << cur->second << std::endl;
    }
    return 0;
}

代码说明mp.begin()mp.end() 返回迭代器,分别指向第一个元素和“尾后”位置。迭代器通过 ++ 操作符前进,通过 ->first->second 访问键和值。

Java 示例

Java 中,Map 是一个接口,TreeMap 是其一种有序的实现。Java 倾向于使用方法调用而非操作符重载。

import java.util.Map;
import java.util.TreeMap;

public class Main {
    public static void main(String[] args) {
        // 声明一个 Map 接口的引用,指向 TreeMap 实现
        Map<String, Integer> map = new TreeMap<>();

        // 使用 put 方法插入键值对
        map.put(“z“, 8);
        map.put(“z“, 1); // 覆盖
        map.put(“y“, 2);
        map.put(“b“, 3);
        map.put(“a“, 4);

        // 打印 Map
        System.out.println(map);

        // 使用 getOrDefault 方法安全访问
        int valueZ = map.getOrDefault(“z“, 42);
        int valueX = map.getOrDefault(“x“, 42); // ‘x‘ 不存在,返回 42
        System.out.println(“z = “ + valueZ + “, x = “ + valueX);

        // 使用 for-each 循环和 entrySet 进行迭代
        // Map.Entry 代表一个键值对条目
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            // 使用 getKey() 和 getValue() 方法获取键和值
            System.out.println(entry.getKey() + “ => “ + entry.getValue());
        }
    }
}

代码说明map.entrySet() 返回一个 Set<Map.Entry>,它实现了 Iterable 接口,因此可以直接用于 for-each 循环。Java 习惯使用 getKey()getValue() 这样的访问器方法来获取数据。

总结

本节课中,我们一起学习了“映射”这一核心数据抽象及其在不同编程语言(Python、PHP、C++、Java)中的具体实现。尽管语法和命名各异(如字典、数组、Map),但它们都提供了存储和访问键值对的基本功能。我们还探讨了“迭代器模式”,它是遍历集合元素的通用抽象,允许我们无需了解底层数据结构细节即可访问所有元素。理解这些跨语言的抽象概念,有助于我们更深刻地把握编程的本质,并在学习新语言时快速触类旁通。

032:C语言面向对象模式的独特之处概览 🧩

在本节课中,我们将探讨C++编程语言在面向对象编程发展中的关键作用,并分析其设计理念如何深刻影响了后续的Python、Java等语言。我们将重点关注运算符重载引用传递这两个核心概念,并通过代码示例展示C++、Python和Java在处理类似问题时的不同设计选择。

C++的历史地位与影响

C语言诞生于1972至1978年间,而C++则于1980年问世。在整个1980年代,C和C++共同演进。随后出现的C#、Java、Python、PHP等语言都深受C++的影响。

在C++出现之前,过程式编程和面向对象编程的开发者群体几乎是割裂的。C++的出现,在某种程度上统一了这两种范式。它既是主流的过程式编程语言,也是主流的面向对象编程语言,为“过程式-面向对象混合”编程模式带来了秩序。1980年后出现的语言,都受到了C和C++的强烈影响。

语法影响:以映射(Map)为例

为了理解这种影响如何随时间演变,我们可以观察一些语法上的传承。

C++作为最早的语言之一,最初是预处理器加编译器,后来发展成独立的编译器。它有一个map概念,可以分别指定键和值的类型,并使用方括号语法。

map[“Z”] = 8;

这行代码本质上是一个插入或更新操作,语法非常简洁。

Python在设计时,不希望语法比C++更复杂。它创建了字典类型,并且由于是动态类型语言,无需声明键值类型。

d[“Z”] = 8

当你开始学习Python时,会觉得这种语法很自然。但其底层实现,更接近Java在1995年的做法。

Java选择了更“纯粹”的面向对象方式,不使用方括号语法,而是使用方法调用。

Map<String, Integer> map = new TreeMap<String, Integer>();
map.put(“Z”, 8);

每种语言都在做出自己的设计选择,我们将在后续内容中重点分析这些差异。

C++的运算符重载

上一节我们看到了不同语言语法的差异,本节中我们来看看C++实现这种简洁语法的核心机制。C++的面向对象设计使得像map这样的高级类,在使用上几乎和intfloat这样的低级基础类型一样简单,特别是它可以重载像方括号[]、加号+、减号-这样的特殊运算符。

其原理是:你可以在C++类中创建一个特殊命名的方法,当编译器遇到特定的语言语法(如方括号)时,会查找并调用这个方法。这个概念被称为运算符重载,意味着运算符的行为由类的编写者控制。

以下是一个示例代码:

class TenInts {
private:
    int values[10]; // 私有成员,外部无法直接访问
public:
    int& operator[](const int& index) {
        return values[index];
    }
};

我们来分析这段代码:

  • 我们创建了一个名为TenInts的类,它内部有一个包含10个整数的私有数组values
  • 我们定义了一个公共方法 int& operator[](const int& index)。当编译器遇到对象[索引]这样的语法时,就会调用这个方法。
  • 该方法的返回类型是 int&,即一个整数引用。这意味着返回值既可以放在赋值语句的左边(写入),也可以放在右边(读取)。
  • 参数是 const int& index,即一个常量整数引用const意味着我们不能在这个方法内部修改index的值。

运算符重载的运行机制

现在,让我们看看这个机制如何在主程序中运行。

int main() {
    TenInts ten; // 创建一个TenInts类型的变量ten
    ten[1] = 40; // 将40存入位置1
}

当编译器看到 ten[1] 时,它会发现TenInts类定义了operator[]方法,于是调用这个方法,并传入参数1。该方法返回一个指向values[1]引用,然后数值40被赋值给这个引用。

接下来执行打印:

cout << “ten[1] contains ” << ten[1];

此时ten[1]位于赋值语句的右侧,它同样会调用operator[]方法,传入1,返回values[1]的引用,然后这个值(40)被打印出来。

再看一个复杂点的例子:

ten[5] = ten[1] + 2;

其执行过程是:

  1. 计算右侧 ten[1] + 2:调用operator[]获取values[1]的引用(值为40),加上2,得到结果42。
  2. 计算左侧 ten[5] =:调用operator[]获取values[5]的引用。
  3. 将右侧的结果42赋值给左侧的引用。

这一切都得益于运算符重载引用传递的能力。

其他语言的设计选择

C++通过传引用调用返回引用实现了强大的运算符重载。然而,后续语言做出了不同的设计决策。

Java 明确放弃了这种设计。Java不希望(也从未打算)在方法调用中通过引用传递值,更不希望方法返回引用。&符号(用于声明引用)对于C++实现运算符重载至关重要,但Java为了避免垃圾回收的复杂性和潜在的内存管理危险,没有引入引用概念。因此,Java既没有C++那样简洁的语法,也不支持运算符重载。

Python 则采取了一种折中方案。它借鉴了C++优美的语法,但底层实现上避免了引用传递。Python通过语法转换来实现类似功能。

以下是Python实现类似TenInts类的代码:

class Ten:
    def __init__(self):
        self.__values = {} # 使用双下划线约定表示“私有”

    def __setitem__(self, index, value): # 对应赋值语句左侧
        self.__values[index] = value

    def __getitem__(self, index): # 对应表达式右侧
        return self.__values[index]

当我们执行 ten[1] = 40 时,Python在运行时将其转换为 ten.__setitem__(1, 40)
当我们执行 print(ten[1]) 时,Python将其转换为 ten.__getitem__(1)

这样,Python无需处理复杂的引用,就实现了优雅的语法。它本质上和Java一样,所有操作都通过方法调用完成,只是编译器/解释器在背后做了语法转换,对开发者隐藏了细节。

总结与影响溯源

本节课中我们一起学习了C++面向对象设计的独特之处及其对后世语言的深远影响。

  • C++的核心特性:通过运算符重载引用传递,使得自定义类型能拥有与内置类型一样直观的语法,赋予了程序员极大的灵活性和控制力(同时也要求更高的责任感)。
  • Python的借鉴与改造:继承了C++优美的语法,但通过内部的双下划线方法(如__setitem__, __getitem__)和运行时语法转换,避免了引用传递的复杂性,使其在拥有垃圾回收机制的同时保持了代码的优雅。
  • Java的纯粹选择:为了简化内存管理和语言设计,彻底放弃了运算符重载和引用语法,要求所有操作都通过显式的方法调用(如put, get)来完成,强调了清晰性和一致性。

这些设计差异源于不同语言哲学:C++信任程序员是“武士”,提供所有可能需要的强大工具;Python和Java则更注重安全性和简易性,通过限制某些能力来防止常见错误。

这种语言的演进与设计师的背景紧密相关。C++之父Bjarne Stroustrup在贝尔实验室与C语言之父Dennis Ritchie等人共事,C++因此深深植根于Unix和C的传统。而Python之父Guido van Rossum在开发Python时,大量借鉴并改进了C++中的模式和惯例(例如使用双下划线表示私有成员的约定)。

正是这些精妙的设计决策和跨语言的灵感借鉴,塑造了我们今天所使用和热爱的编程语言生态系统。理解这些底层原理,能帮助我们更好地理解每门语言的特性和其背后的设计思想。

033:在基于C的对象中实现封装 🔒

在本节课中,我们将学习如何在C语言中实现面向对象编程的核心概念之一:封装。我们将通过重构一个简单的“映射”(Map)数据结构来演示,将全局函数转换为类的方法,并区分公共和私有属性。


概述

之前我们已经深入探讨了对象和理论。现在,是时候停止深潜,开始编写一些代码了。我们将从一个简单的概念开始:封装。之后,我们还会学习迭代,但本节我们只专注于封装。

本节的大部分代码你其实已经写过。我们主要是进行重构和调整,将那些我们按约定命名的、允许调用代码使用的函数,通过一些指针操作,移动到“类”中。

真正的成果在于,map->putmap->getmap->del 这些方法现在以属性的方式被命名和访问。我们调用的函数成为了类本身的属性。除此之外,并没有太大不同。我们还将更明确地定义类中哪些部分是公共的,哪些是私有的。


定义私有结构:Map Entry

我们从一个 map entry 开始。这个结构构成了链表中的节点。

  • key 是一个字符串。
  • value 是一个整数(为了简化示例)。
  • 我们需要像之前一样动态分配 key 的内存。
  • 我们还有 prevnext 指针。

这里的关键是,prevnext 前面有双下划线__)。这意味着它们是私有的。我们决定 keyvalue公共的,就像Python的做法一样,不在它们前面加双下划线,并在心里记住调用代码允许使用它们。

struct map_entry {
    char *key;          // 公共属性
    int value;          // 公共属性
    struct map_entry *__prev; // 私有属性
    struct map_entry *__next; // 私有属性
};

定义主类结构:Map

map 结构体大部分看起来很简单。

  • 我们有 headtailcount。你已经维护它们一段时间了。
  • 这些是私有属性,所以我们用双下划线重命名它们。

然后,我们有一系列公共方法,共有五个。关键点在于,这些是指向函数的指针

例如,void (*put)(struct map *self, char *key, int value); 意味着我们在结构体中分配了一个名为 put 的变量,它是一个函数指针。这个指针指向一个返回 void 的函数。我们不仅定义了用于访问函数的属性,还定义了它的调用规则:它返回 void,并接受三个参数:一个指向 struct map 的指针 selfchar *keyint value

最终,这并不是把代码放在这里(像JavaScript那样)。它实际上是一个64位的数字,即一个指向函数起始地址的指针。函数的方法签名必须匹配,所以我们在这里定义了方法签名。但在分配时,我们实际上是为 putgetsizedumpdel 各分配了一个指针。

struct map {
    // 私有属性
    struct map_entry *__head;
    struct map_entry *__tail;
    int __count;

    // 公共方法(函数指针)
    void (*put)(struct map *self, char *key, int value);
    int (*get)(struct map *self, char *key, int default_value);
    int (*size)(struct map *self);
    void (*dump)(struct map *self);
    void (*del)(struct map *self);
};

理解这里的括号非常重要,因为我们同时定义了属性名、它的使用规则以及我们最终要指向的函数的方法签名。


构造函数

构造函数与你之前写的没有太大不同。我们需要构建这些函数:__map_put__map_get__map_size 等。它们位于源代码中某个更靠前的位置。

在构造函数中,我们只是说:map->put(公共属性 put)等于 &__map_put 函数的地址。非常简单:& 是“取地址”运算符。get 是那个函数的地址,size 是那个函数的地址,dump 是那个函数的地址,以此类推。

struct map *map_create() {
    struct map *m = malloc(sizeof(struct map));
    m->__head = NULL;
    m->__tail = NULL;
    m->__count = 0;

    // 将函数指针指向实际的函数实现
    m->put = &__map_put;
    m->get = &__map_get;
    m->size = &__map_size;
    m->dump = &__map_dump;
    m->del = &__map_del;

    return m;
}

这展示了 map 结构体本身的大小:head 是一个64位指针,tail 是一个64位指针,count 可能是一个64位或32位整数,putgetsizedumpdel 都是64位指针。所以 map 结构体本身大约在10个字(words)或更少,这再次关系到效率。


实现类方法

你可能已经拥有了 __map_put__map_get__map_size__map_dump__map_del 所需的大部分代码。

Map Dump 方法

__map_dump 方法很简单。self 是指向 map 的指针,它有 __head__next。我们只需遍历直到 cur 等于 NULL

注意,我们没有给 cur 加双下划线,因为它只是这个函数内部的自动变量,与外部世界无关。你会注意到我们直接访问 __head__next,因为我们在类内部。所以,在类的方法中访问私有属性是完全合法的,这很正常,我们不必隐藏它们。

void __map_dump(struct map *self) {
    struct map_entry *cur = self->__head;
    while (cur != NULL) {
        printf("Key: %s, Value: %d\n", cur->key, cur->value);
        cur = cur->__next;
    }
}

当我构建这样的代码时,第一件想让它工作的事情就是某种“dump”工具,因为调试时需要它。我会在每行代码后都放一个 map_dump,直到东西开始工作,然后再把 map_dump 去掉。所以这就是为什么我要让你们也这样做。

Map Delete 方法

像大多数删除操作一样,关键是画出结构图,找出哪些部分是动态分配的(来自 malloc),然后确保调用 free 释放它们。

我们只需遍历链表。同样,我们在类内部,所以可以愉快地使用双下划线属性。遍历时,顺序很重要:我们先释放 key(记住,这是一个我们 malloc 的字符串指针),我们不需要释放 value,因为它实际上是 map_entry 结构体的一部分。然后我们前进到下一个节点,再释放当前的 map_entry 本身。

void __map_del(struct map *self) {
    struct map_entry *cur = self->__head;
    struct map_entry *next;

    while (cur != NULL) {
        next = cur->__next; // 先保存下一个节点
        free(cur->key);     // 释放动态分配的 key
        free(cur);          // 释放节点本身
        cur = next;         // 移动到下一个节点
    }
    // 最后释放 map 结构体本身
    free(self);
}

最终,我们遍历整个链表,释放了所有的 keyentry,归还了所有数据,然后释放了那大约10个字的 map 结构体本身。

Map Get 方法

__map_get 很简单,只要你有一些像 __map_find 这样的代码。__map_find 会完成所有繁重的工作,它可以查看 __head__next 等,写一些循环,应该不难。

同样,__map_find 是私有的,但我们在类内部,所以可以随意使用私有成员。

int __map_get(struct map *self, char *key, int default_value) {
    struct map_entry *found = __map_find(self, key);
    if (found != NULL) {
        return found->value;
    } else {
        return default_value;
    }
}

Map Put 方法

__map_put 方法需要你编写。但如果你思考一下:如果你调用 __map_find 并找到了条目,就更新它的值并返回;如果没有找到,就构造一个新的 map_entry 并添加到链表末尾。同样,我希望你现在可以轻松完成这些。

void __map_put(struct map *self, char *key, int value) {
    struct map_entry *found = __map_find(self, key);
    if (found != NULL) {
        // 键已存在,更新值
        found->value = value;
    } else {
        // 键不存在,创建新节点并添加到链表末尾
        struct map_entry *new_entry = malloc(sizeof(struct map_entry));
        new_entry->key = strdup(key); // 复制 key 字符串
        new_entry->value = value;
        new_entry->__prev = self->__tail;
        new_entry->__next = NULL;

        if (self->__tail != NULL) {
            self->__tail->__next = new_entry;
        } else {
            // 链表为空,新节点也是头节点
            self->__head = new_entry;
        }
        self->__tail = new_entry;
        self->__count++;
    }
}

总结

本节课中,我们一起学习了如何在C语言中实现封装。这是一个非常简单的部分,我们所做的只是将全局命名函数转换为类的方法。我们通过双下划线强制执行私有规则,然后在 map 结构中声明函数指针,并在构造函数中设置它们。其余部分基本上只是重构你已经拥有的代码。

通过这种方式,我们为C语言的数据结构赋予了更清晰的面向对象接口,使代码组织更有序,并明确了公共API与内部实现的边界。

034:在C语言中构建迭代器抽象 🧩

在本节课中,我们将学习如何在C语言中构建迭代器抽象。迭代器是一种设计模式,它允许我们以统一的方式遍历不同的数据结构,而无需关心其内部实现细节。通过使用迭代器,我们可以编写出更通用、更易于维护的代码。


迭代器的必要性

上一节我们介绍了数据结构的封装。本节中,我们来看看为什么需要迭代器。直接使用数据结构内部的指针(如 headnext)来遍历数据,会破坏抽象边界,使代码与特定实现紧密耦合。例如,链表有 headnext,但哈希表或树结构则没有。为了编写能适用于不同底层数据结构(如链表、哈希表、树)的通用代码,我们需要一个统一的遍历方式。

核心思想是:调用代码不应关心对象如何被遍历。我们需要一个通用的循环概念。


迭代器模式解析

迭代器是一个独立的对象。你创建它时,它从数据结构的起始位置开始。然后,你反复调用 next 方法来获取下一个元素。迭代器内部会维护状态(如当前指针),每次调用 next 都会返回当前元素并自动前进到下一个位置。当没有更多元素时,它会返回一个特殊值(如 NULL)。

以下是迭代器模式的关键步骤:

  1. 从数据结构(如 Map)获取一个迭代器对象。
  2. 在一个循环中,反复调用迭代器的 next 方法。
  3. 处理 next 方法返回的元素。
  4. next 返回 NULL 时,结束循环。
  5. 最后,销毁迭代器。

与Python迭代器的对比

为了帮助理解,我们可以参考Python中的迭代器。在Python中,对一个字典调用 iter() 会返回一个字典键迭代器对象,而不是包含所有数据的列表。

d = {'A': 1, 'B': 2, 'C': 3}
it = iter(d) # 获取迭代器
while True:
    try:
        key = next(it) # 获取下一个键
        print(key)
    except StopIteration: # 没有更多元素时抛出异常
        break

我们的C语言实现将模仿这种模式:迭代器不包含所有数据,而是包含指向内部数据的指针,并通过 next 方法依次提供。


C语言迭代器实现

现在,我们来看看如何在C语言中具体实现一个迭代器。我们将为之前构建的 Map 类添加迭代功能。

迭代器结构体定义

首先,我们定义一个迭代器结构体 MapIt。它的内部细节(如 current 指针)是私有的,对外只暴露两个方法:next 和用于清理的 del 函数。

typedef struct mapit {
    // 私有成员
    MapEntry *_current;
    // 公共方法指针
    MapEntry *(*next)(struct mapit *it);
    void (*del)(struct mapit *it);
} MapIt;

迭代器的创建与初始化

迭代器由 Map 对象的某个方法(例如 map_iterator)创建并初始化。在构造函数中,我们将 current 指针指向链表的第一个节点(head),并将函数指针指向具体的实现函数。

MapIt *map_iterator(Map *m) {
    MapIt *it = malloc(sizeof(MapIt));
    it->_current = m->_head; // 私有操作,从Map的head开始
    it->next = mapit_next;   // 指向next方法的实现
    it->del = mapit_del;     // 指向del方法的实现
    return it;
}

next方法的工作原理

next 方法是迭代器的核心。它返回当前指向的元素,然后将内部指针 _current 移动到下一个位置。当 _currentNULL 时,表示遍历结束,返回 NULL

以下是 next 方法的一个简化实现逻辑:

MapEntry *mapit_next(MapIt *it) {
    if (it->_current == NULL) {
        return NULL; // 遍历结束
    }
    MapEntry *retval = it->_current; // 保存当前要返回的条目
    it->_current = it->_current->next; // 内部指针前进到下一个
    return retval; // 返回当前条目
}

状态变化图示
假设链表为:head -> [z=22] -> [w=42] -> NULL

  1. 初始状态it->_current 指向 [z=22]
  2. 第一次调用 next:返回 [z=22],同时 it->_current 前进到 [w=42]
  3. 第二次调用 next:返回 [w=42],同时 it->_current 前进到 NULL
  4. 第三次调用 next:发现 it->_currentNULL,返回 NULL,表示结束。

使用迭代器的客户端代码

使用迭代器的客户端代码变得非常简洁和通用。它只需要知道如何获取迭代器、调用 next 以及判断结束。

以下是使用迭代器遍历Map并打印所有键值对的示例代码:

// 获取Map的迭代器
MapIt *it = map->iterator(map);

while (1) {
    // 获取下一个条目
    MapEntry *cur = it->next(it);
    if (cur == NULL) { // 判断是否结束
        break;
    }
    // 处理当前条目(MapEntry的key和value是公共的)
    printf("Key: %s, Value: %d\n", cur->key, cur->value);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/37803759d728039e286da3d071c0fe8b_23.png)

// 清理迭代器
it->del(it);

这段代码的结构与之前展示的Python代码完全平行。无论底层 Map 是链表、哈希表还是树实现的,这段遍历代码都无需任何修改


总结与展望

本节课中,我们一起学习了在C语言中构建迭代器抽象。我们理解了为什么需要迭代器来分离关注点,实现了迭代器结构体及其核心的 next 方法,并编写了通用的客户端遍历代码。

通过引入迭代器,我们为 Map 抽象奠定了坚实的基础。现在,我们可以开始创建 Map 的不同实现(如基于链表的 ListMap、基于哈希的 HashMap、基于树的 TreeMap),而使用这些 Map 的客户端代码完全不需要改变。这让我们能够在不影响上层应用的情况下,在底层实现更复杂的数据结构和算法,逐步逼近像Python字典那样高效和灵活的实现。

这正是抽象和封装的强大力量:它允许我们在抽象层之下灵活操作,实现真正酷炫的功能。🚀

035:底特律办公室答疑 🎤

在本节课中,我们将跟随查克教授,探访密歇根大学《C语言编程课》在底特律的线下教学现场。我们将了解这门课程如何从线上走向线下,并聆听参与学生们分享的学习经历与感受。


大家好,我是查克。我现在在底特律市中心,参与一个名为“底特律周六课堂”的项目。这个项目将《Python for Everybody》的课程材料转化为线下实体课程。你们将会见到授课老师。

这件事的有趣之处在于,你们可能经常听我谈论如何构建这门课程来培训教师,而不是由我亲自授课。你们或许认为我经常坐在教室后排,观察我的课程是如何被讲授的。但说实话,自从我创建《Python for Everybody》以来,这是我第一次亲眼看到学生和主讲老师围绕课程材料进行互动。

接下来,我想请大家做个自我介绍,简单谈谈自己,说说学习这门课程的感受,或者向其他同学打个招呼。我们都熟悉这个流程。那么,我们开始吧。

以下是学员们的自我介绍:

  • 卡拉:大家好。这是我自90年代以来第一次重返大学课堂。我决定重返校园,现在正努力适应如今的学习方式。
  • 拉斐尔:大家好,我叫拉斐尔。目前,我算是一名有抱负的学生。我打算重返学校攻读计算机科学,在完全进入这个领域之前,我参加这个项目是为了更好地理解编程。
  • 斯泰西:大家好,我是斯泰西。底特律加油!我一直想学习编程,这门课太棒了。塔玛拉是一位非常出色的老师,她在办公时间给了我很多帮助。查克博士的视频教程也非常精彩。我感觉在很短的时间内,我就学到了很多编程知识。我是一名退休教师,所以这可能会成为我的新职业。
  • 塔玛拉:大家好,我是塔玛拉,是这门课的协调员。能来到这里我真的很兴奋。我接触Python大约四年了,而这门课实际上是我在密歇根大学开始学习时最早接触的课程之一。所以现在能来到这里帮助其他学生学习Python,我感到非常激动。
  • 雷文:大家好,我叫雷文。这是我第一次上编程课。无论是在大学还是工作中,我都尽可能避开它。但我现在想了解编程和计算机是如何工作的,看看我能从中获得哪些技能,并应用到日常生活中,让生活更轻松。这门课程非常有帮助,并且开阔了我的思维。
  • 玛拉妮:大家好,我叫玛拉妮。今天是我的生日。我是一名网站设计师和社交媒体经理。这次Python学习机会让我能够真正磨练我在奥克兰大学学习的技能,加以拓展,并开始进入编程领域。因为我最终的目标是成为一名前端开发人员。所以,我非常高兴能有这个机会。
  • 查曼:大家好,我叫查曼。我来到这里是因为知识就是力量。就这样。
  • :大家好,我是丹。我在Ally Financial工作,担任技术战略的执行董事。Ally是“底特律周六课堂”的大力支持者。我很自豪能参与整个项目。看到大家在学习真的很棒。我们需要更多拥有技术技能的人才,这些都是高薪职业。因此,任何我们能做的、有助于促进经济流动性,并为城市贡献更多技术专家的事情,都是非常了不起的。

丹的发言为这次活动画上了完美的句号,非常感谢大家。这是一个开始,这是这门课程第一次以这种方式在底特律讲授。我真心认为,正如丹所说,这将日益成为一个典范,让更多人进入技术领域,让更多底特律人掌握技术,从而获得更好的就业机会,为底特律带来更好的经济成果。能成为其中的一小部分,我深感荣幸。谢谢大家。


本节课中,我们一起探访了《C语言编程课》在底特律的线下教学实况。我们看到了线上课程如何成功转化为线下互动课堂,并听到了来自不同背景的学员们真诚的分享。他们的故事表明,学习编程不仅是获取技能,更是开启新可能、促进个人与社区发展的重要途径。

036:构建哈希映射与树映射

概述

在本节课中,我们将学习如何在C语言中实现两种重要的数据结构:哈希映射和树映射。我们将从回顾已构建的映射抽象和链表实现开始,然后分别构建基于哈希表和二叉搜索树的映射实现。课程最后,我们将用C语言重写一个经典的Python词频统计程序作为综合应用。


回顾:映射抽象与链表实现

上一节我们介绍了映射(Map)的抽象概念,并创建了一个基于链表的映射实现。映射是一种存储键值对(Key-Value Pair)的数据结构,它允许我们通过键来高效地插入、查找和删除对应的值。

我们之前构建的链表映射是一个有序映射,其迭代器会按照键插入的顺序返回元素。然而,链表在查找效率上存在局限,平均时间复杂度为O(n)。


哈希映射(Hash Map)简介

本节中我们来看看哈希映射。哈希映射通过一个哈希函数将键映射到一个固定大小的数组(通常称为“桶”或“buckets”)的索引上。每个数组元素指向一个链表,用于处理哈希冲突(即不同键映射到同一索引的情况)。

其核心思想可以用以下伪代码描述:

index = hash_function(key) % array_size
bucket = array[index]
在 bucket 对应的链表中查找或插入键值对

哈希映射的平均查找、插入和删除时间复杂度可以接近O(1),前提是哈希函数分布均匀且桶的数量足够。

以下是实现哈希映射的关键步骤:

  1. 设计数据结构:需要定义表示整个哈希表的结构、桶的结构以及链表的节点结构。
  2. 实现哈希函数:需要一个函数将任意键(如字符串)转换为一个整型哈希值。
  3. 处理冲突:通常采用链地址法,即每个桶存放一个链表。
  4. 实现基本操作:包括插入(put)、查找(get)和删除(remove)。

树映射(Tree Map)简介

接下来,我们将探讨树映射,特别是基于二叉搜索树(BST)的实现。在树映射中,键值对按照键的顺序存储在树节点中,这使得它可以高效地进行范围查询和有序遍历。

树映射的核心是二叉搜索树性质:对于任意节点,其左子树中所有节点的键都小于该节点的键,其右子树中所有节点的键都大于该节点的键。

查找和插入操作的平均时间复杂度为O(log n),但在最坏情况(树退化成链表)下会变为O(n)。更高级的实现(如AVL树、红黑树)可以保证平衡,从而维持O(log n)的性能。

实现树映射的挑战在于维护树的平衡以及正确地在插入和删除时更新节点间的链接(left, right, parent指针)。正如我在构思代码时所画的草图,需要仔细追踪“比当前键大的最小节点”和“比当前键小的最大节点”来找到正确的插入位置并维护树的结构。


给初学者的建议

学习实现这些数据结构时,请记住以下几点:

  • 从理解开始:确保你完全理解哈希表和二叉搜索树的基本概念。
  • 动手画图:在编码前,在纸上画出数据结构的图示和操作流程(如插入一个节点),这能极大帮助理清逻辑。
  • 接受调试过程:第一次就写出完美代码的概率很低。使用调试工具或打印指针地址(%p)来跟踪程序状态是正常且必要的学习过程。
  • 利用测试:课程提供的测试程序就像是单元测试,能系统性地验证你的实现是否正确处理了各种边界情况。
  • 重在理解:目标是理解原理和实现过程中的挑战,而不是简单地复制代码。克服一两个错误并修复它们,对于深入理解至关重要。

课程终点与起点

现在,我们来到了本课程的尾声。回顾整个学习历程,就像我们最初在《Python for Everyone》中看到的那段词频统计代码一样——它通过字典(一种哈希映射)优雅地解决了问题。

作为本课程的收官之作,我们将用C语言重新实现那个经典的词频统计程序,但这次使用的是我们自己构建的哈希映射或树映射。这标志着一段学习里程的结束,同时也象征着你在系统编程和数据结构理解上新征程的开始。


总结

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

  1. 回顾了映射抽象和已有的链表映射实现。
  2. 深入探讨了哈希映射的原理与实现步骤,它通过哈希函数实现高效访问。
  3. 介绍了树映射的概念,特别是基于二叉搜索树的实现,它维护了键的有序性。
  4. 提供了实现复杂数据结构时的实用建议,强调了画图、调试和通过测试理解的重要性。
  5. 预告了课程的综合实践——用自建的C语言映射数据结构重现代码词频统计,将理论知识与实践应用完整结合。

037:理解哈希计算原理 🧮

在本节课中,我们将要学习如何实现一个基于哈希的映射(Map)。哈希映射是编程面试中最常见的问题之一,其实现原理优美而简单。我们将从基础的链表映射出发,逐步理解哈希映射的数据结构和工作原理,并学习如何通过哈希函数将键(Key)分配到不同的“桶”(Bucket)中。

从链表映射到哈希映射

上一节我们介绍了链表映射(List Map)的实现。本节中我们来看看如何将其扩展为哈希映射(Hash Map)。

链表映射的结构相对简单:它包含一系列通过指针链接在一起的条目(Entry),每个条目存储一个键值对。哈希映射的核心思想是使用多个链表,并通过一个哈希函数来决定每个键值对应该存储在哪个链表中。

哈希映射的内部数据结构包含一个固定数量的“桶”数组。每个桶本质上是一个独立的链表。当我们插入或查找一个键时,首先通过哈希函数计算该键的哈希值,然后通过取模运算确定它属于哪个桶,最后在该桶对应的链表中进行操作。

以下是哈希映射与链表映射的核心结构对比:

  • 链表映射 (ListMap): 包含一个头指针 (head)、一个尾指针 (tail) 和一个计数器 (count)。
  • 哈希映射 (HashMap): 包含一个桶数组 (heads[]tails[]) 以及桶的数量 (n_buckets)。

哈希函数的工作原理

理解了哈希映射的数据结构后,我们来看看决定数据分布的关键:哈希函数。

哈希函数的作用是将任意长度的输入(例如一个字符串)映射为一个固定大小的整数值(哈希值)。在哈希映射的上下文中,我们随后会对此哈希值进行取模运算,以确定其对应的桶索引。

一个简单但有效的字符串哈希函数实现如下:

int hash_function(const char* key, int n_buckets) {
    int hash = 0;
    for (int i = 0; key[i] != '\0'; i++) {
        hash = (hash << 3) ^ key[i]; // 左移3位后与当前字符进行异或操作
    }
    return hash % n_buckets; // 取模得到桶索引
}

这个函数遍历字符串的每个字符,通过位左移和异或操作不断更新哈希值。异或操作有助于增加结果的随机性。最后,通过对桶数量取模,得到一个介于 0n_buckets-1 之间的索引号。

例如,假设我们有8个桶 (n_buckets = 8):

  • "hi" 可能被哈希到桶 1
  • "hello" 可能被哈希到桶 7
  • "world" 可能被哈希到桶 4

处理哈希冲突

需要理解的一个重要概念是哈希冲突:两个不同的键经过哈希函数计算后,可能得到相同的桶索引。由于我们的每个桶都是一个链表,处理冲突非常简单:只需将哈希到同一桶的多个条目依次添加到该桶的链表中即可。

因此,哈希映射的插入和查找操作分为两步:

  1. 计算桶索引bucket_index = hash_function(key, n_buckets)
  2. 操作链表:在 heads[bucket_index] 指向的链表中执行插入、查找或删除操作,这与普通的链表映射操作完全相同。

构建哈希映射

现在我们已经掌握了哈希映射的原理和哈希函数的工作方式。接下来,构建一个哈希映射就变得非常直观。

实际上,你可以基于链表映射的代码来创建哈希映射。主要修改是将对单个链表的操作,转化为先计算桶索引,再对特定桶的链表进行操作。许多链表映射中的方法(如插入、查找)都可以被复用,只需在开头增加计算哈希桶的步骤。

一个更复杂的实现会包含“重哈希”(Rehashing)机制,即当条目数量增多导致单个桶内的链表过长时,自动增加桶的数量并重新分配所有条目,以保持高效性。为了简化初学者的理解,本教程中的示例将使用固定数量的桶。


本节课中我们一起学习了哈希映射的核心概念。我们了解到哈希映射通过哈希函数和桶数组将查找时间平均化,从而实现了高效的插入和查找操作。其本质是多个链表的组合,利用哈希函数快速定位到目标链表。我们还学习了一个简单的字符串哈希函数实现,并理解了哈希冲突是如何通过链表自然解决的。掌握这些原理,是理解现代编程语言中字典或哈希表这类数据结构的基础。

038:在C语言中构建哈希映射对象

在本节课中,我们将学习如何基于已有的链表映射(ListMap)代码,通过最小的改动,构建一个哈希映射(HashMap)对象。我们将重点关注构造函数、查找、插入、迭代器等核心部分的实现与修改。


概述

我们已经理解了哈希函数的基本概念。现在,我们将实际动手构建一个哈希映射的实现。核心思路是复制已有的链表映射代码,并尽可能少地修改它。如果你已经拥有可工作的链表映射代码,你会发现这个过程非常简单。

构造函数 🏗️

首先,我们来看新的构造函数。关键变化在于引入了“桶”(buckets)的概念。

我们分配一个哈希映射结构体。它仍然包含用于封装的方法,如 putgetsizedump 和迭代器。这些名称没有改变,只是结构体名称从 ListMap 变为了 HashM

我们将桶的数量设置为8。初始化所有8个桶,将每个桶的 headtail 指针设为 NULL。请记住,这本质上就是8个独立的链表。同时,将元素计数 count 初始化为0。

以下是构造函数的关键部分:

HashM* HashM_create() {
    HashM* hm = malloc(sizeof(HashM));
    hm->buckets = 8;
    hm->count = 0;
    for (int i = 0; i < hm->buckets; i++) {
        hm->heads[i] = NULL;
        hm->tails[i] = NULL;
    }
    // ... 初始化函数指针
    return hm;
}

这个过程非常直观,前提是你理解了哈希链表。如果不理解,建议回顾相关课程内容。


查找函数 🔍

上一节我们介绍了构造函数,本节我们来看看查找功能是如何修改的。

我们之前使用 ListMap_find 函数来查找映射条目。现在,我们需要 HashM_find 函数。

ListMap_find 接收整个映射对象(self)、要查找的键(key),并从链表的头部开始遍历。HashM_find 的关键变化在于,它需要指定从哪个桶开始查找。

因此,HashM_find 的代码与 ListMap_find 几乎完全相同,区别仅在于:它不是从单一的 head 开始,而是从一个桶数组 heads 中,根据传入的 bucket 索引找到对应链表的头部,然后遍历该链表。

以下是查找逻辑的伪代码表示:

MapEntry* HashM_find(HashM* self, char* key, int bucket) {
    MapEntry* current = self->heads[bucket];
    while (current != NULL) {
        if (strcmp(current->key, key) == 0) {
            return current;
        }
        current = current->next;
    }
    return NULL;
}

Get 与 Put 操作

现在,让我们看看 getput 操作如何利用新的查找函数。

Get 操作

HashM_get 函数接收一个键。其步骤是:

  1. 根据键和桶的总数(例如8)计算目标桶的索引。
  2. 调用 HashM_find 函数,并传入计算出的桶索引。
  3. 如果 find 返回 NULL,则返回默认值;否则,返回找到的值。

ListMap_getHashM_get,实际上只修改了一行代码(即加入了计算桶索引的步骤)。

Put 操作

回顾一下在链表映射中 put 的操作:

  1. 调用 find。如果找到条目,则更新其值。
  2. 如果没找到,则分配一个新条目,将其 next 指针设为 NULL,并将其链接到链表末尾。
  3. 链接操作需要考虑链表为空或非空的情况。建议画图来理解这些指针操作。

对于 HashM_put,流程类似,但有一个关键区别:

  1. 首先,运行哈希计算来确定键对应的桶索引。
  2. 调用 HashM_find 并在该特定桶中查找。
  3. 如果找到,更新值。
  4. 如果没找到,分配新条目,并链接到该桶对应链表的末尾。

转换的关键:将原来操作单一链表 headtail 的代码,改为操作数组 heads[bucket]tails[bucket]。在编写代码时,可以简单地将原代码中所有的 head 替换为 heads[bucket],将 tail 替换为 tails[bucket]


调试与迭代器

转储(Dump)函数

为了调试,我们需要一个 dump 函数来显示所有桶的内容。这与链表映射的 dump 不同,我们需要遍历所有桶,并显示每个桶的索引及其中的键值对。编写一个良好的调试工具对于发现和修复代码错误至关重要。

迭代器 🚶

迭代器是一个独立的对象,它允许我们逐个访问映射中的所有元素,而无需暴露内部结构。

对于链表映射,迭代器相对简单,它只需要跟踪当前节点(current),并通过 next 方法依次后移。

对于哈希映射,迭代器更为复杂,因为它需要遍历多个链表(多个桶)。

以下是哈希映射迭代器的核心思路:

  1. 结构:迭代器对象需要存储对哈希映射的引用(map)、当前正在查看的桶索引(bucket)以及该桶内的当前条目指针(current)。
  2. 初始化:构造迭代器时,bucket 设为0,current 指向0号桶的链表头部(可能为 NULL)。
  3. next 方法:这是最复杂的部分。
    • 如果 current 不为 NULL,则返回它,并将 current 指向同一链表中的下一个节点。
    • 如果 currentNULL,则意味着当前桶的链表已遍历完。此时,需要将 bucket 索引加1,并移动到下一个非空桶的链表头部。
    • 这个过程需要一个 while 循环来跳过那些为空的桶。
    • 如果 bucket 索引超过了最大桶数,则返回 NULL,表示迭代结束。

这个过程需要仔细处理边界条件。强烈建议在编写代码时绘制示意图,跟踪 bucketcurrent 指针的状态变化。


总结

本节课中,我们一起学习了如何将链表映射转换为哈希映射。我们主要完成了以下工作:

  1. 修改构造函数:引入桶数组,初始化多个链表。
  2. 调整查找逻辑:使 find 函数能够在指定的桶内进行查找。
  3. 实现 Get/Put:通过计算哈希值确定桶索引,从而将操作限定在特定链表上。
  4. 构建迭代器:设计了能够跨多个桶链表进行遍历的复杂迭代器。

哈希映射的核心优势在于通过分散数据到多个桶来提升平均性能。虽然我们目前没有实现动态扩容(再哈希),但你已经掌握了构建一个基础、完整哈希映射的所有关键步骤。理解并绘制指针操作图是掌握这些数据结构实现细节的最佳方法。

039:使用二叉树作为数据结构

概述

在本节课中,我们将学习如何使用二叉树来实现一个高效、有序的键值存储结构——树映射。我们将探讨二叉树的基本概念、插入与查找操作,并最终将其与链表结合,构建一个支持快速查找和有序迭代的复合数据结构。


什么是二叉树?🌳

上一节我们介绍了基础的键值存储结构。本节中,我们来看看一种更高效的数据结构——二叉树。

二叉树是一种数据结构。在树映射中,每个条目包含一个左指针和一个右指针。键值较小的条目位于当前节点的左侧,键值较大的条目位于右侧。例如,如果根节点是 H=42,那么键 A 会放在左侧,键 T 会放在右侧。

与链表拥有头尾节点不同,二叉树有一个根节点。根节点是树的顶端,从它开始,通过一系列左或右的选择可以遍历整棵树。每个树映射条目包含一个键值对,并且我们将其键和值设为公开,因为它只是一个条目。条目中没有“下一个”指针,取而代之的是左指针和右指针。我们通过抽象,只向用户提供一组操作树映射的方法,而隐藏内部使用左右指针的实现细节。

以下是树映射条目的基本结构概念:

struct TreeMapEntry {
    char* key;
    int value;
    struct TreeMapEntry* left;
    struct TreeMapEntry* right;
};


二叉树的插入操作

理解了二叉树的结构后,我们来看看如何向其中插入新的键值对。

插入操作的过程如下:从根节点开始,将要插入的键与当前节点的键进行比较。根据比较结果,决定向左子树或右子树移动,直到找到合适的插入位置或发现键已存在。

例如,假设我们有一个已构建的树(为了演示清晰,它被设计为平衡的),现在要插入 G=25

  1. 从根节点 H 开始。G < H,因此向左移动。
  2. 到达节点 DG > D,因此向右移动。
  3. 到达节点 FG > F,因此再次向右移动。
  4. 此时,F 的右侧为空,这正是插入 G 的位置。

这个过程就像在弹珠机中,弹珠从顶部落下,根据一系列精确的左/右决策,最终到达底部某个位置。通过遵循这个算法插入,树能始终保持正确的键序。

以下是插入算法的核心逻辑描述:

如果 当前节点为空:
    在此位置创建新节点
否则 如果 待插入键 < 当前节点键:
    递归地向左子树插入
否则 如果 待插入键 > 当前节点键:
    递归地向右子树插入
否则: // 键相等
    更新当前节点的值


树的遍历与调试输出

为了验证树的结构是否正确,一个有效的调试方法是实现一个能可视化树结构的输出函数。

我们可以使用递归来实现树的深度优先遍历,并打印出带有缩进的结构。递归非常适合处理这种具有自相似结构的问题。如果不使用递归,可能需要手动维护一个栈,这会复杂得多。

dump 函数的工作原理是:

  1. 接收一个当前节点指针和当前深度。
  2. 如果当前节点为空,则返回(递归的终止条件)。
  3. 根据当前深度打印相应数量的缩进(如竖线和空格),然后打印当前节点的键值对。
  4. 递归地调用自身处理左子树(深度+1)。
  5. 递归地调用自身处理右子树(深度+1)。

这种遍历顺序被称为“中序遍历”(左-根-右),它能以排序的顺序输出键。在调试时,先确保 dump 函数正常工作至关重要,这样你就可以通过观察输出来验证其他操作(如插入)是否正确。


二叉树的查找操作

插入之后,另一个核心操作是根据键快速查找对应的值。

查找操作的逻辑与插入类似,但更简单:

  1. 从根节点开始。
  2. 将目标键与当前节点的键比较。
  3. 如果相等,返回当前节点的值。
  4. 如果目标键更小,则进入左子树继续查找。
  5. 如果目标键更大,则进入右子树继续查找。
  6. 如果到达空节点(NULL),说明键不存在,则返回一个预设的默认值。

这种查找方式非常高效。在平衡的二叉树中,查找的时间复杂度为 O(log n),因为每次比较都能排除大约一半的子树。以下代码片段展示了查找的核心循环:

while (current != NULL) {
    cmp = strcmp(target_key, current->key);
    if (cmp == 0) {
        return current->value; // 找到
    } else if (cmp < 0) {
        current = current->left; // 向左
    } else {
        current = current->right; // 向右
    }
}
return default_value; // 未找到

这种设计的美妙之处在于,用户可以通过传递不同的默认值来灵活处理键不存在的情况。


结合链表以支持迭代

虽然二叉树在查找和插入方面表现优异,但为其构建一个迭代器来顺序访问所有元素却比较麻烦。问题在于,遍历树需要跟踪“当前”位置,而树结构中的“当前”是隐含在递归调用栈中的。要构建迭代器,必须显式地维护一个栈来记录访问路径,这增加了复杂性。

为了解决这个问题,我们采用一个常见技巧:组合数据结构。我们将为每个树节点同时添加指向前后节点的指针(prevnext),从而在维护二叉树的同时,也维护一个有序的双向链表。

  • 树层:使用 leftright 指针和 root 根节点进行快速的查找和插入。
  • 链表层:使用 prevnext 指针和 head 头节点来支持高效、简单的顺序迭代。

这样,TreeMap 结构体将同时包含 roothead。插入新节点时,我们利用二叉树快速找到其正确位置,同时更新链表指针,将其插入到链表的相应位置。最终,我们得到一个既能进行 O(log n) 快速查找,又能像链表一样轻松进行顺序迭代的“链式树映射”。


总结

本节课中,我们一起学习了二叉树作为数据结构的核心应用。我们首先了解了二叉树的定义和插入、查找的基本算法。接着,我们探讨了如何使用递归来遍历和调试树结构。然后,我们指出了纯二叉树在支持迭代器方面的不足,并引入了将二叉树与双向链表相结合的精妙设计。这种“链式树映射”复合数据结构,兼具了二叉树查找高效和链表迭代方便的优点,是实现有序字典的一种强大方式。在接下来的课程中,我们将具体实现这个复合数据结构的插入代码。

040:向链表树映射中插入数据

在本节课中,我们将学习如何为我们的树映射(Tree Map)数据结构实现 put 方法。核心挑战在于,我们需要同时维护两个数据结构:一个有序链表和一个二叉搜索树。我们将通过详细的步骤和图示,理解如何高效地插入新数据,并处理各种边界情况。


🧠 核心概念与挑战

本节我们将探讨实现 put 方法的核心思想。关键在于,我们需要同时更新两个数据结构:一个用于快速查找的二叉搜索树和一个用于顺序遍历的有序链表。

二叉搜索树的查找性能是 O(log n),而有序链表的查找性能是 O(n)。这意味着,对于一个包含一百万个条目的树,平均只需约20次比较即可找到目标;而链表则需要平均五十万次比较。因此,我们主要利用树进行搜索,仅在需要顺序迭代时才使用链表。但在执行 put 操作时,我们必须同时维护两者。

这并非易事。仅仅通过搜索或询问AI可能无法直接获得正确答案。你需要真正理解背后的原理。绘制示意图在此过程中极具价值。代码本身最终会显得简洁明了,但实现过程可能需要反复尝试和修正。


📊 数据结构回顾

在深入代码之前,我们先回顾一下使用的数据结构。每个条目(Entry)同时属于两个结构:

  • 树结构:通过 leftright 指针连接。
  • 链表结构:通过 next 指针连接。

我们的树映射(TreeMap)类包含两个主要属性:

  • root:指向二叉搜索树的根节点。
  • head:指向有序链表的头节点。

在构造函数中,我们将两者都初始化为 null,表示一个空映射。


🌀 插入算法详解

接下来,我们详细分析插入新键值对时的各种情况。算法需要处理以下几种场景:

  1. 插入到空映射。
  2. 插入到链表头部(新键最小)。
  3. 插入到链表尾部(新键最大)。
  4. 插入到链表中间。
  5. 替换已存在的键的值。

我们将使用两个辅助变量 largest_lesssmallest_greater(在视频中称为 leftright)来追踪“间隙”。它们就像面包屑,记录着我们遍历树时遇到的小于新键的最大节点和大于新键的最小节点,从而为链表插入精确定位。

情况一:插入到空映射

这是最简单的情况。如果 headroot 都为 null,我们只需创建一个新节点,并让 headroot 都指向它即可。

if self.head is None:
    new_entry = Entry(key, value)
    self.head = new_entry
    self.root = new_entry
    return

情况二:查找与替换

在遍历树寻找插入位置时,我们使用 cur 指针从根节点开始移动,并不断更新 largest_lesssmallest_greater

  • 如果 cur.key > new_key,则向左子树移动,并更新 smallest_greater = cur
  • 如果 cur.key < new_key,则向右子树移动,并更新 largest_less = cur
  • 如果 cur.key == new_key,则找到匹配项。这是最简单的情况:只需更新该节点的值,无需修改链表或树的结构。

if cur.key == key:
    cur.value = value  # 替换值
    return

情况三:插入到链表中间

cur 变为 null 时,我们找到了树中的插入位置。此时,largest_lesssmallest_greater 正好定义了新节点在链表中的前驱和后继。

  1. 创建新节点 new_entry
  2. 将新节点插入树中:如果 new_key < parent.key,则作为左子节点;否则作为右子节点。
  3. 更新链表指针:
    new_entry.next = smallest_greater
    if largest_less is not None:
        largest_less.next = new_entry
    else:
        # 说明新节点是最小的,需要更新head
        self.head = new_entry
    

情况四:插入到链表头部或尾部

这是情况三的特殊形式:

  • 头部插入:当 largest_lessnull 时,说明新键比所有现有键都小。新节点应成为新的链表头 (self.head)。
  • 尾部插入:当 smallest_greaternull 时,说明新键比所有现有键都大。新节点的 next 指针应为 null,并且前驱节点 (largest_less) 的 next 指针应指向它。


💡 总结与建议

本节课中,我们一起学习了如何为同时维护有序链表和二叉搜索树的映射数据结构实现 put 方法。我们分析了插入到空映射、链表头、链表尾、链表中间以及替换现有值等多种情况,并理解了如何使用 largest_lesssmallest_greater 这两个“面包屑”变量来精确定位链表中的插入点。

实现此类复杂数据结构的代码充满挑战,出错是过程的一部分。关键在于理解算法本身,然后耐心地通过绘图和调试来完善实现。测试所有边界情况至关重要。完成这个练习将极大地加深你对指针操作和数据结构协同工作的理解。

接下来,我们将回到“Python for Everybody”课程,对所学内容进行总结和整合。

041:使用链式哈希映射在Python和C语言中统计词频 📊

在本节课中,我们将回顾整个课程的旅程,并回到最初的起点。我们将重温在《Python for Everybody》课程中展示的第一个程序——统计文件中出现频率最高的单词。这次,我们将使用C语言和本课程中构建的树形映射数据结构来实现相同的功能,以此展示从Python到C的编程思维转换,并巩固对核心数据结构的理解。

概述

这是一段相当漫长的旅程。我们使用C语言构建了一个完整的面向对象模式,回顾了面向对象编程,并实现了多个不同的Python对象。通过这种方式,我们理解了C++、Java和Python等语言在底层是如何工作的。现在,我们即将结束对所有精彩数据结构的探索。

在课程的最后,我喜欢做的一件事是回到最初。有些同学可能是从《Python for Everybody》这门课开始接触编程的。现在,我想通过重温那门课中展示的第一个程序来结束本课程。

重温Python的起点

这个程序来自《Python for Everybody》的第一章。这是一个统计文件中出现频率最高单词的示例。

以下是Python版本的代码逻辑:

  • 读取一个文件名。
  • 创建一个字典。
  • 读取所有行并分割成单词。
  • 遍历所有单词,使用 counts.get(word, 0) 来获取当前计数(如果单词不存在则默认为0)。
  • 将计数加1后存回字典。
  • 最后,通过一个简单的最大循环(max loop)遍历字典项,找出出现次数最多的单词及其计数并打印出来。

在C语言中实现

现在,我们将使用本课程中构建的树形映射代码来实现相同的计数功能。

以下是实现步骤:

首先,我们需要进行变量和结构的初始化。

  • 创建一个树形映射对象作为我们的“字典”。
  • 声明树形映射条目和迭代器,用于后续遍历。
  • 创建字符数组来存储文件名和单词(注意:这里使用固定大小的数组,在实际应用中需注意缓冲区溢出风险)。
  • 声明循环变量 ij,以及用于记录最大值的 countmax_valuemax_key

接下来是文件读取和单词处理的核心逻辑。

  • 使用 scanf 获取文件名,然后使用 fopen 以读取模式打开文件。
  • 使用 fscanf 循环读取文件中的每一个单词(格式符为 %s)。
  • 对每个读取到的单词,调用 to_lower 函数将其转换为小写。
  • 在单词末尾小心地添加字符串结束符 \0
  • 使用 MapGet 函数从映射中获取该单词的当前计数(类似于Python中的 get 方法,默认值为0)。
  • 使用 MapPut 函数将单词和(计数+1)存回映射中。
  • 处理完文件后,关闭文件句柄。

最后是找出出现频率最高单词的步骤。

  • 初始化 max_keyNULLmax_value 为 -1(因为计数都是非负整数,所以-1可以作为初始值)。
  • 获取映射的迭代器,并进入一个无限循环。
  • 在循环中,使用迭代器获取下一个条目。如果条目为 NULL 或迭代完成,则跳出循环。
  • 如果当前条目的值大于或等于当前的 max_value,则更新 max_keymax_value
  • 循环结束后,释放迭代器。
  • 打印出出现频率最高的单词(max_key)及其出现次数(max_value)。
  • 最后,删除整个映射对象以释放内存。

总结与展望

本节课中,我们一起学习了如何将Python中简单的词频统计程序,用C语言和自定义的树形映射数据结构重新实现。这标志着一段漫长旅程的结束,但更是一个新的开始。

这些是最基础的数据结构,也是经典的数据结构,它们源自《算法导论》(CLRS)等经典著作,是四十多年来人们一直在学习的核心内容。掌握这些数据结构就像掌握了烹饪中的煎蛋卷,看似简单,但却是理解更复杂、更高级算法和系统的基础。它们是构建一切复杂程序的基石。

如果你已经认真完成了本课程的所有练习并掌握了这些内容,那么你的编程之旅可以继续向前。你可以打开像《算法导论》这样的大部头著作,从任意一章开始,无论是Dijkstra算法、Alpha-Beta剪枝还是其他任何算法,你都有能力去理解它,并用C语言实现它。因为你已经掌握了如何分配内存、创建包含指针的结构体以及如何释放它们。

本课程的目标不是教你这本书里的每一个算法,而是教你什么是算法,以及所有算法赖以构建的基础模块是什么。我祝愿你们好运,并鼓励你们在编程的道路上继续前行。你的旅程并未结束,它才刚刚开始。

042:回顾与展望 🎯

在本节课中,我们将回顾整个课程的核心项目,并探讨如何将C语言中的数据结构概念与Python的高级数据结构联系起来。我们将看到,通过实现Python风格的字符串、列表和字典类,可以深刻理解接口与实现分离的概念。

课程回顾与项目缘起

上一节我们完成了课程的主体内容。本节中,我们来看看整个项目的总结与延伸思考。

这门《给所有人的C语言编程》课程对我而言是一个长达四年半的项目,涵盖了编写教材、创建自动评分系统、录制讲座、在Coursera平台上线以及发布到互联网等全部工作。在课程进行到某一阶段时,我遇到了Kernighan和Ritchie著作的第六章。我当时思考的问题是:有哪些好的示例可以提供给那些可能已经学过《Python for Everybody》的学生使用?

我的想法是:为什么不直接实现一些Python类呢?这样可以观察它们的复杂度,并验证接口与实现的概念是否有效。事实证明,这个想法效果非常好。

实现Python字符串类

以下是实现Python字符串类的核心思路。

我们按照Kernighan和Ritchie第六章的方法,构建了一个Python字符串类。这个类采用了可扩展字符数组的设计,它会预先分配一块空间,当空间被填满时再进行扩展。

struct PyString {
    int length;    // 已使用的字符数
    int alloc;     // 已分配的总空间
    char *str;     // 指向字符数组的指针
};

在这个结构中,length表示我们已经使用了多少个字符,alloc表示我们分配了多少空间。我们自动在字符串末尾添加一个\0字节。当你不断添加字符时,最终length会变为9(加上一个\0字节),而alloc是10,这意味着我们无法再添加字母“D”。此时,我们使用realloc函数将其扩展到20个字符,这样就有空间存放字母“D”和字符串结束符了。

实现Python列表类

接下来,我们看看如何实现一个Python列表类。

构建Python列表类非常自然,就是将其实现为一个链表。链表涉及两种结构体:一种是链表本身,它包含头指针、尾指针和一个记录项目数量的计数器;另一种是节点。

// 链表结构
struct PyList {
    struct ListNode *head;
    struct ListNode *tail;
    int count;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/umich-c-evrn/img/105f70b7aec4e17e5214b016b5411441_5.png)

// 链表节点结构
struct ListNode {
    char *text;           // 指向字符串(字符数组)的指针
    struct ListNode *next; // 指向下一个节点的指针
};

在我们的构造函数中,我们分配对象,然后将headtail指针设置为NULL以表示链表当前为空,并将计数器count设置为0。

当我们向列表中添加元素时,我们操作这些指针:将head指向列表的开头,将tail指向我们添加的最后一个项目。我们使用malloc为字符串分配内存,这样我们就获得了一个指向字符串的指针,这个字符串由列表“拥有”,而不是属于传入的参数。然后我们连接next指针。

这里有一些细节需要注意:如果链表为空(即headNULL),我们只需将head指向新分配的节点。如果tail不为NULL,我们将最后一个节点的next指向新创建的节点,并将tail更新为新节点。接着,我们分配字符串内存,将参数中的文本复制到text中,存储该指针,并更新计数器count

如果我们再添加一个元素,需要将新节点链接到tail之后。这样,tailnext指针就不再是NULL,而是指向新创建的节点。然后我们将tail更新为指向这个新节点,而新节点的next指针设为NULL,这是我们结束链表的方式。

要遍历这个链表,我们从head开始,查看当前项目,然后移动到next指向的节点,查看那个项目,如此反复,直到nextNULL时结束。通过这种方式,我们成功构建了一个功能相当完善的Python列表对象。

实现Python字典类

最后,我们探讨如何实现一个Python字典类。

正如你所料,当我们转而构建字典类时,直接参考了第6.6节,创建了一个带“桶”的哈希表。这种基于桶的哈希映射可能是最常见的编程面试题。

字典本质上是一组“桶”,每个桶是一组指向链表的指针。哈希函数接收一个键,并生成一个大的伪随机数(这意味着它是确定性的,但分布广泛),目的是减少冲突。例如,“John Smith”和“Joe Smith”的哈希值应该不同。

我们再次遵循Kernighan和Ritchie的方法:使用键计算哈希值(通常是一个大整数),然后根据桶的数量进行取模运算。因此,这些桶本质上就是链表。

// 字典结构(以4个桶为例)
struct PyDict {
    int nbuckets;
    struct ListNode *heads[4]; // 每个桶的头指针数组
    struct ListNode *tails[4]; // 每个桶的尾指针数组
    int count;
};

// 字典节点结构
struct DictNode {
    char *key;    // 指向键字符串的指针
    int value;    // 整型值
    struct DictNode *next;
};

在新建操作中,我们分配字典对象,决定桶的数量(在示例结构中定义为4个元素的数组),然后将所有头尾指针初始化为NULL,以表明链表为空。这里需要说明的是,这个实现没有包含扩展机制,为了保持数据结构的简洁性,我暂时省略了重新哈希和扩展的功能。

插入元素时,我们使用哈希函数计算索引,然后操作对应的链表。如果你比较列表代码和字典代码,会发现很多部分看起来相似,区别在于字典的headtail是通过哈希计算并取模后选定的。

从Python视角反思

完成所有这些实现后,我开始深入思考。我逐渐不再仅仅从C语言和Kernighan & Ritchie的角度来看待这些代码,而是更多地从Python的视角来审视。

我不禁想问:我是不是无意中做了和Guido van Rossum(Python创始人)完全相同的事情?Guido van Rossum是否也像我们大多数人在七八十年代那样读过这本书?他是否也曾说过:“我要创建一个列表对象,它将是一个链表;我还要创建一个哈希对象,它将是一组由链表组成的桶”,就像大家都会做的那样?

这种思考将C语言底层的数据结构实现与高级编程语言Python的设计联系了起来。

总结

本节课中我们一起回顾了如何用C语言实现Python的核心数据结构。我们构建了可扩展的字符串类、基于链表的列表类以及基于哈希桶的字典类。通过这些实践,我们不仅深入理解了Kernighan和Ritchie经典著作中的数据结构,也窥见了高级语言底层实现的巧妙思路。这趟旅程的结束,正是理解编程语言设计与实现之间深刻联系的开始。

043:Python内部对象实现解析

在本节课中,我们将跟随Python创始人Guido van Rossum的讲述,了解Python语言早期版本中核心数据结构的实现思路与设计决策。我们将探讨字符串、列表和字典等对象是如何从ABC语言中汲取灵感,并结合C语言的实践需求进行重新设计的。


设计理念的起源

上一节我们介绍了课程背景,本节中我们来看看Guido van Rossum最初的设计理念。他是在构建ABC语言的简化版本时开始设计Python的。他脑海中同时考虑了语法和对象实现。

他非常熟悉ABC语言数据结构的实现方式,并且对于如何以不同于ABC的方式来实现语法和数据结构,已经有了相当成熟的想法。

语法设计的改变

对于语法,Guido对ABC的主要不满在于它使用大写字母作为语言的关键字。ABC这样做有其原因,但Guido认为这不是一个好理由,并且在他看来,这对于一个前黑客来说显得非常糟糕。因此,他想要改变语法。

他知道自己想要实现基于缩进的语法。他曾参与过ABC的解析器开发,所以他知道如何实现这些功能。他有一些自己的想法,但他清楚自己想要什么。

实际上,他最初是从一个解析器开始的。词法分析器和解析器实际上是他为这门语言编写的第一批代码。

基本数据类型的实现

在开始编写之前,Guido对如何实现基本数据类型有非常具体的想法。

  • 引用计数机制:他会使用他从ABC中熟知的相同引用计数机制。
  • 整数类型:他会以类似的方式实现整数,因为这是一个简单的选择。他希望所有东西都是对象,这也是他赞同ABC的一点。
  • 任意精度整数:任意精度整数出现得很快,但他认为它们并非立即出现。最初有一个32位的整数类型,以及一个独立的long类型(该类型一直保留到Python 2结束),后者是任意精度的。此外还有一个浮点类型。

对于更复杂的数据结构,数值类型的实现与ABC并没有太大不同或特别有趣之处。

字符串的实现

但对于其他部分,Guido看到了ABC的做法,即所有东西(甚至字符串)都实现为树结构。他不喜欢这种方式,因为他希望与系统调用和C语言库进行交互。

他希望字符串是任意长度的,但他希望它们是一个线性缓冲区。即使长字符串需要一次性分配大块缓冲区,他也认为没关系。他设想大多数字符串不会那么长,他会确保它对任何大小都有效,但会针对他想象中许多Python程序会频繁使用的短字符串进行优化。

这是一个明智的选择,但并非自动或直观地就能得出这是正确答案的结论。

由于编写了大量C代码,并且希望Python能够用C语言进行扩展,这也是他早期做出的选择之一。他希望以一种自然的方式链接回C代码。导入系统就是其中的一部分。

以下是字符串实现的核心思想:

// 概念上,字符串对象包含一个指向字符缓冲区的指针
typedef struct {
    PyObject_HEAD
    char *data;       // 指向字符数据的指针
    Py_ssize_t length; // 字符串长度
    // ... 其他元数据
} PyStringObject;

当Python只有一个月或两个月大时,如果你要追加一个字符串到一个循环中,它基本上是通过重新分配和复制来实现的吗?不,字符串始终是不可变的。是的,它的操作是:计算结果的大小,分配一个新的字符串对象,然后将两个原始字符串复制到其中。

有一个内部的字符串调整大小操作,其本意是仅在你构建字符串但尚未将其展示给任何人之前使用。他需要这个功能,因为他设想了一个I/O系统,在这个系统中,你可能会读取一行但不知道这行有多长,或者你可能将整个文件读入一个字符串但不知道文件有多长。

所以,他会分配一个足够大的缓冲区,读取数据到该缓冲区,然后如果发现分配了1000字节但只读取了15字节,他会重新分配以释放剩余的985字节。

发布前的思考

这是在Guido发布Python第一个版本之前的思考,当时甚至还没有0.0.1版本。他希望字符串以那种方式实现,包括一个细节:如果你有一个10字节的字符串,你会分配11字节并在末尾放置一个空字节(\0)。这样,如果你碰巧想把这个字符串传递给一个期望以空字符结尾的字符串的C库函数,你就不必复制它。

当然,如果字符串中间可能有一个空字节,事情仍然可能出错。但如果你知道或信任情况并非如此,你就不必为了确保有空字节而制作一个带有一个额外字节的副本。这个空字节是数据结构的一部分,当然只在C语言端可见。

列表的实现

对于列表,Guido有类似的想法。在ABC中,列表是一种树结构,即使你从小列表构建一个非常大的列表,它也非常高效。他认为树结构太复杂了,所以他说,好吧,列表只是一个可变的数据结构。这在ABC中是一个不存在的概念,因为在ABC中所有东西都是不可变的。

从实用角度出发,他更喜欢他的大型数据结构(指列表和字典)是可变的。因此,列表的实现始终只是一个指向可以重新分配的缓冲区的指针。在Python中我们称之为列表,但它实际上是一个数组,一个指针数组,每个指针指向一个对象。我们知道这个数组的长度,这记录在对象头中。

所以,如果没有空间了,我们就重新分配。如果我们从中间丢弃某些东西,那么我们会将所有内容移过来,并且也可能重新分配。在过去的34年里,该数据结构唯一改进的地方是:最初的实现没有过度分配。

他依赖于realloc进行某种分块处理。所以,如果你将某个东西从1000字节重新分配到1004字节,他想象realloc内部可能将所有内容对齐到16字节或更多的块中,因此它不会移动那块内存。但最终,这被证明要么是错误的,要么就是效率低下。

最终,列表对象头中保存了两个大小。一个告诉Python用户列表数组的长度,另一个告诉数组中有多少空间。第二个大小总是大于第一个。然后,当你需要更多空间时,你会过度分配,这样你就不会在每次追加时都调用realloc

字典的实现

接下来,我们来探讨Guido构建最早的字典结构时,它与ABC有何不同。同样,在ABC中字典是树结构,并且字典按键保持排序顺序。键总是可排序的(在ABC中,所有相同类型的东西至少都是可比较的)。

再次,Guido认为这太复杂了。他至少浏览过Knuth的《计算机程序设计艺术》第3卷,其中解释了哈希表的概念。他也熟悉Perl中的哈希表(他认为它们叫做hashes)。

他翻阅了Knuth第3卷的目录,选择了一个哈希算法和一种感觉合适的哈希表组织方式。他选择了开放寻址法,而不是为桶使用单独的链表。

最初的字符串哈希算法,他不确定是否也是从Knuth那里挑选的哈希函数,但他很可能这么做了。

字典顺序的演变

在Python 5.3.7到5.3.8版本之间,字典保持了顺序。这有点像ABC的“复仇”,但顺序不同。在ABC中,键是排序的。如果你的字典中有数字1、12和500,在ABC中键的顺序是1、12、500。如果你插入11,它会插入到1和12之间。

另一方面,在保持顺序的新版Python字典中,它是插入顺序。这不是因为Python字典要求键类型是可排序或可比较的,它只需要是可哈希的。当然,它也需要有相等性比较(这个字符串是否等于那个字符串),但你永远不需要看这个字符串是否小于那个字符串。

那么,Guido是如何让它保持插入顺序的呢?那是在一个他已经长期放弃或委托了大多数基本数据类型开发的时期。他认为日本有一位开发者多年来一直在提高字典类型的效率。

原始设计(他从Knuth那里选择的开放寻址法)的一个问题是空间效率非常低。因为对于每个键值对,你必须有一个指向键的指针(假设是4字节),一个指向值的指针(另一个4字节),然后还有哈希值(又一个4字节)。所以哈希表是一个结构体数组,每个结构体12字节长。

为了使查找、插入和删除算法能够工作,哈希表的填充率不能超过三分之二。这意味着如果你有一个1000个条目的数组,你最多只能存储大约600-700个键值对,因此你有300-400乘以12字节的空间被浪费了。

他们日本的贡献者想出了一种使用分离表的方法。哈希表只包含一个索引,而实际的键值对和哈希值保存在一个没有空洞的表中。它们基本上是连续增长和填充的,并记住所有东西的位置。

首先,他偶然发现了使用这些分离数组的算法(他认为他改进过几次),然后他偶然发现了一个特性:哦,嘿,在第二个表中它恰好保持了插入顺序。确实是在第二个表中,因为现在基于哈希值跳转的表只是包含一个指向另一个表的索引。

如果哈希表元素少于256个,该数组只需要一个字节来存储索引,因此还有额外的空间节省。那里有各种各样的巧妙设计。

对链表的看法

Guido对Python不使用经典链表感到惊讶。这可能与他希望与C语言互操作的愿望有关,这种思想贯穿始终:可扩展和填充的块似乎比通用链表更好。

他当时不知道的是,这种架构也适用于现代硬件,因为它有更好的缓存局部性(这个概念在1989年他甚至可能都不知道存在)。所以他认为他只是因为某些其他原因不喜欢链表而避免了它们。

这正是他希望一遍又一遍听到的,因为他一生都假设链表是计算机科学一直在思考的东西。Python中有很多指针,但经典的链表使用得并不多。


本节课中我们一起学习了Python早期核心数据结构的实现思路。我们了解到,Guido van Rossum在设计Python时,从ABC语言中汲取了“万物皆对象”和引用计数等思想,但为了更好的C语言互操作性、实用性和性能,对字符串、列表和字典的实现进行了重大革新。他选择了线性缓冲区而非树结构,采用了可变设计,并基于哈希表实现了字典,这些决策深刻影响了Python的特性与效率,并意外地使其适应了现代硬件的缓存特性。

044:解读Guido在Python-0.0.9中的字符串和列表类实现

在本节课中,我们将深入探讨Python创始人Guido van Rossum在早期Python版本(0.0.9)中实现字符串和列表类的思路。我们将重点分析他为何没有采用经典的链表结构,而是选择了可扩展数组,并理解其背后的设计考量。

一个关键的认知转变

上一节我们介绍了数据结构的基础概念。本节中,我们来看看Guido在实现Python核心数据结构时所做的具体选择。

我希望你仔细观看了那段采访。我在编辑与杰出人物的访谈时,常做的一件事是,我提出的问题并非总是完美的。这时我通常会意识到,我的假设是错误的。

在编辑你刚刚观看的采访视频时,我并没有剪掉所有我困惑的部分。原因在于,我希望你能看到我那些被证明是错误的假设时刻。那时我正在脑海中快速思考,试图提出一个好问题,并寻求澄清。因此,在那段视频中,你可以看到我向Guido学习的过程。

总结来说,Guido几乎没有使用链表。他没有为列表对象使用链表,没有为字符串对象使用链表,也没有为字典对象使用链表。

这令人惊讶。我完全错了。Python 1.0的列表和字典对象是指针的可扩展数组,而根本不是链表。

设计灵感的来源

尽管Guido和我们大多数人一样,精通K&R(《C程序设计语言》)第六章的内容,但他实现数据结构时参考的并非此书。

他更近期的经验来自ABC语言和C++。更重要的是,他参考了ABC语言的数据结构实现方式,或者更具体地说,他审视了ABC的实现并认为其方式不佳。但他并没有因此就回头采用K&R第六章的方法。作为一名计算机科学家,我的直觉是:K&R第六章是金科玉律,为何不采用呢?

我认为他是从简单的可扩展数组开始的列表实现。这很有道理,因为你要么线性查找(这不是最快的方式),要么按位置查找(如 list[5])。既然如此,为何不使用数组呢?一旦你和他交谈并听他解释,你就会明白:“哦,是的,我懂了,我懂了。”

字典也未使用链表

更令人惊讶的是,他甚至没有在字典中使用链表。你可以看到我在提问时难以置信的样子,仿佛在说:“请告诉我你在字典和哈希桶中使用了链表,就像过去35年所有面试题那样。”而答案是:没有。

他参考了一份更早的文献,这对于我们70年代的所有算法学习者来说是真理:Donald Knuth的《计算机程序设计艺术》第1、2、3卷。这里展示的就是Donald Knuth的第3卷,我曾扫描它以获取其中的内容。这里,我们翻到……“通过开放地址法解决冲突”这一部分。

他当时所做的,正是那个时代优秀计算机科学家的做法:阅读这类书籍,从中寻找构建哈希映射的灵感。他知道自己想实现哈希。我们在K&R第六章也学过哈希,但他采用了一种非常不同的方式,这涉及到冲突解决和线性探测。

性能与架构的考量

再次强调,核心数据结构中并没有真正的链表。事实证明,在视频中我们也稍微讨论过,不使用链表具有性能优势

有趣的是,如果你回顾Guido实际构建Python的时代,我们使用的计算机并不严重依赖缓存内存架构。这意味着我们使用的CPU和内存速度匹配得更好,因为所有东西都还在冰箱大小的计算机里。速度都足够慢,以至于CPU并不比内存快太多。

但是,当CPU在80年代末90年代初变成单芯片CPU,甚至快速浮点运算也集成到一个非常大、非常热的芯片上时,内存就跟不上了。因为芯片内部(可能只有半英寸到一英寸)的速度变得极快,内存无法跟上。于是他们在CPU内部加入了缓存来匹配CPU的速度。

但链表会导致在内存中“跳来跳去”的访问模式,这会破坏缓存。因此,如果你尝试运行一个基于纯链表的操作,比如一个长度为10,000的列表,在1992-94年的计算机上性能会非常差。而Guido在1989-90年左右编写了Python,他当时并没有考虑“我必须设计一个缓存高效的数据结构”。他只是觉得数组不错,但结果证明,这种方式对缓存架构非常友好

某种程度上,如果你回过头去看并说“让我们回头加上链表吧”,你会说“不”,因为链表如果以我教你K&R第六章时的那种方式实现,会对性能产生非常坏的影响。这就是为什么我在和Guido交谈时,虽然一直犯错,但却感到欣喜。因为我在学习,我觉得:“哦,这太酷了。”

代码实现对比分析

接下来,让我们做一点回顾,相关的示例代码已提供给你。

让我们看看我对Python 1.0列表的重新实现(不是K&R的方式,而是Guido的方式)。你会发现,如果你花时间比较链表实现和可扩展数组实现,你会意识到后者更简单。

首先,我们只有一个结构体,即列表。我们有一个表示列表已分配空间大小的变量,就像我们之前实现的字符串一样(我实现的字符串非常接近Guido的方式,但列表我搞错了)。然后是一个指针数组。

char** 表示一个数组。第一个 * 表示数组,第二个 * 表示数组的内容是指针,这是一个指向字符的指针数组。

以下是实现的关键步骤:

  1. 我们分配结构体,将分配大小 alloc 初始化为2,长度 length 初始化为0。
  2. 然后分配一个包含2个指针的数组。我们知道长度为0,所以没有指针被使用。
  3. 当追加元素时,首先检查是否有空间。如果有,就分配新字符串,将参数复制到该字符串中,根据 length 指示的末尾位置放入,然后 length 加1。
  4. 当空间不足时(length >= alloc),我们扩展它(这里简单起见,每次增加2个条目),然后调用 realloc
  5. realloc 可能会返回一个相同但尾部有更多空间的指针,也可能返回一个全新的指针。如果是新指针,我们需要重新赋值,并复制原有数据。由于我们只关心 length,新增的空间条目我们甚至不需要初始化为0。
  6. 然后我们保存新分配的字符串,将其放入末尾,length 加1。

相比之下,链表实现需要更多的指针操作和内存分配。在K&R的链表追加中,你需要分配新节点,然后分配字符串。而在Guido的方式中,你只分配字符串,只是偶尔重新分配 items 数组。

链表实现中总是让我需要画图才能理清的部分,是中间那段处理头尾指针的代码:

if (self->head == NULL) {
    self->head = new_node;
}
if (self->tail != NULL) {
    self->tail->next = new_node;
}
self->tail = new_node;

这段逻辑远不如Guido方式中的 self->items[self->length++] = saved; 这样直观和简洁。

设计哲学与总结

自1972年以来的许多年里,我们几乎是以一种荣誉勋章的方式在使用链表。而Guido并没有这样做的强烈意愿。无意中,他采用的可扩展数组方法对缓存非常友好,并且查找速度快,因为你永远无法通过下标(如 list[27])快速查找链表,而用Guido的方式,list[27] 是一个非常廉价的操作。

本节课中,我们一起学习了Guido van Rossum在早期Python中实现列表和字符串的核心思想。关键点在于:

  1. Python 1.0 的核心数据结构(列表、字典)基于指针的可扩展数组,而非链表。
  2. 这一设计选择受到了ABC语言和Knuth著作的影响,而非直接遵循K&R的经典链表。
  3. 可扩展数组的实现更简单,代码更直观。
  4. 更重要的是,这种结构天然对现代CPU的缓存架构更友好,能提供更好的随机访问性能。
  5. 这提醒我们,即使是被广泛教授的经典方案(如链表),也并非是所有场景下的最优解,实际设计需要权衡简单性、性能与时代背景。

下一节,我们将深入探讨Guido如何实现Python 1.0的字典。

045:解读Guido在Python-0.0.9中的字典类实现 🔍

在本节课中,我们将要学习Guido van Rossum在1989至1991年间为Python 1.0构建的字典实现。我们将通过分析其核心数据结构与算法,来理解早期Python字典的工作原理。

背景与代码来源

上一节我们介绍了哈希表的基本概念,本节中我们来看看Guido的具体实现。这段示例代码可以在 /code 目录下的 epilogue 代码中找到,具体文件是 p1Dict.c

关键之处在于,Guido van Rossum当时并没有阅读《C程序设计语言》第六章中Kernighan和Ritchie的哈希表实现。相反,他参考了一份更早的、更侧重于纯粹数据结构和算法的文档的第518页。这份文档在当时是我们编写高效、快速代码的“圣经”,也是我们实现复杂算法的指南。Guido发现了这份资料,并决定不采用链表法。这部分原因也源于他在ABC语言项目中的经验。

核心数据结构:基于数组的开放寻址法

接下来,我们深入探讨其核心数据结构。这是一种基于数组的哈希概念,具体实现为开放寻址法。

以下是其核心思想:

  • 开放哈希(数组形式):这是一个基于数组的哈希概念。
  • 桶结构:在桶的样式中,存在一个哈希链表数组。但关键在于,这个数组本身存储所有内容,而不是存储指向数组外部元素的指针。
  • 开放寻址:开放寻址法解决了当初始哈希值导致冲突时,如何探测并找到空闲槽位的问题。我们努力避免哈希冲突,但冲突无法完全避免。

冲突解决机制:循环迭代探测

那么,当发生冲突时,系统如何找到下一个可用位置呢?其基本方法是进行循环迭代。

具体流程如下,如果我们查看代码中的L3行,它会执行减一操作。如果结果小于0,则将 I 设置为 I + M,然后跳回步骤L2。用语言描述可能不如图示直观。

实例图解

为了更好地理解,让我们通过一个图示例子来具体说明。假设我们有一个包含8个键值对的数组。在C语言实现中,这些最终会成为指针(指向键的指针和指向值的指针),但在Knuth的算法描述中,他并不关心具体类型,对他而言,所有内容都只是变量。所以,这就是一个键值对数组。

哈希计算的关键在于,它使用相同的哈希计算方法和相同的取模运算,这个模运算基于桶的数量。

但是,当它选中一个位置时...


本节课中我们一起学习了Guido van Rossum为早期Python字典设计的实现方案。我们了解到他采用了基于数组的开放寻址法,而非链地址法,并通过循环迭代来解决哈希冲突。这种设计选择影响了Python字典性能的基础,为后续版本的高效实现奠定了基础。

046:Python 3.7字典类的重新实现解析

在本节课中,我们将探讨Python 3.6到Python 3.7版本之间字典实现的关键变化。我们将了解字典如何从无序结构转变为维护插入顺序,并深入解析其背后的数据结构设计思想。

🧠 Python 3.7字典的顺序维护机制

上一节我们介绍了字典的基本概念,本节中我们来看看Python 3.7如何实现顺序维护。在Python 3.6到3.7的演进中,字典开始维护插入顺序,而非键的顺序。

其核心思想在于,哈希是一种在数组中快速找到起始位置的方法,但这并不意味着所有数据都必须存储在哈希链表中。在Python 3.7中,实现方式类似于我们之前用树形映射练习中编写的代码,即同时维护多个数据结构。

在Python 3.6中,字典没有顺序保证。在代码执行过程中,顺序可能在重哈希时改变。这种顺序是伪随机的,源于哈希函数本身,并且由于重哈希和哈希映射重组,其顺序在多次插入间甚至无法保持一致。

Python 3.7将哈希索引的概念与键值存储分离开来。这使得遍历Python 3.7字典的行为更类似于遍历Python列表(按顺序迭代)。虽然字典不提供像列表那样的下标访问语义,但其本质上是一个Python列表加上一个用于快速查找的哈希索引。

对于插入和获取操作依然快速,而遍历字典则完全像遍历一个Python 1.0版本的列表。通过键查找和插入键值对仍然非常迅速。

🏗️ 数据结构剖析:索引与条目分离

为了理解上述机制,让我们看看具体的数据结构。struct PyDict 包含分配信息、长度和一个条目数组 items

但现在,我们多了一个整数数组 index。在示例代码中,index 的大小被设定为 items 的两倍。这意味着我们始终有空间,最终负载因子为0.5。

以下是核心结构的简化表示:

struct PyDict {
    Py_ssize_t ma_used;       // 当前已用的条目数
    Py_ssize_t ma_allocated;  // 分配的总槽位数
    PyDictEntry *ma_entries;  // 条目数组(维护插入顺序)
    Py_ssize_t *ma_indices;   // 哈希索引数组
};

items 是一个列表。例如,我们按顺序插入 ZYX,它们会保持插入顺序。新的插入总是追加在末尾。因此,我们看到的是一个填充了四分之三的列表。

但我们现在不像在Python 1.0中那样关心 items 的负载因子,重要的是 index 的负载因子。因为 indexitems 的两倍大,其负载因子永远不会超过50%。这意味着当需要扩大列表时,我们会重新分配内存,同时也会扩大 index。因此,我们永远不会超过50%的负载因子,这使得操作非常平滑和简单。

关键在于,index 是一个整数数组。每个整数中存储的,正如箭头所示,仅仅是键值对在 items 列表中的索引位置。

这本质上是两个同时存在的数据结构:

  • index 是一个哈希表。
  • items 是一个列表。
  • index 中的值指向列表中的偏移量。

虽然示例代码没有实现,但Guido(Python之父)可以轻松地在列表和字典的 items 之间共享部分代码和优化。

📝 总结与设计影响

本节课中我们一起学习了Python字典实现的演进。我们从Guido的设计中学到,他喜爱realloc(可扩展数组)和指向对象的指针。链表在Python核心数据结构中几乎不被使用,这被证明是一个非常好的选择。一旦你开始查看代码,会发现它出奇地简单。

大约10年后,内存管理从realloc移到了Python自身。因为realloc的行为不如预期那样可预测。最终,底层出现了垃圾回收的概念。现在,代码中使用realloc的地方已被Python分配器取代。realloc负责提供大块内存,然后由Python管理、垃圾回收和清理等。因此,现代Python对realloc巧妙性的依赖大大降低。

关于Python的设计影响,Guido受到了多种语言的启发。他使用过C++,并做了一系列实验。某种程度上,他的疑问是:C++是否足够强大,能完成他想在Python中实现的功能?他发现C++的实验有些令人失望,因此他选择使用C语言来构建Python。但他从C++中学到了如何在过程式编程语言之上分层构建面向对象的概念。所以C++产生了很大影响。

ABC语言对Python的影响可能比想象中更大。Guido喜欢ABC的某些方面,也想改进其另一些方面。ABC有很多很酷的类型,使用引用计数处理分配和释放,他喜欢所有这些。但它在内部使用B树(一种常用于数据库的结构),而非二叉树。此外,它没有让用户定义类的机制。所有面向对象的概念都内置于语言及其实现中,用户无法在ABC中定义自己的对象。

ABC很好地完成了它的设计目标。Guido精通ABC并曾参与其工作,因此他知道要从ABC中借鉴什么,也知道要在ABC的基础上构建什么。ABC的语法风格清晰,你可以看到像 splitin 等概念,以及 for line in document 这种循环中隐式迭代的方式,这些都直接进入了Python,只是他改用了小写关键字。他还希望实现真正的面向对象,并且更贴近C语言库,因为ABC并不关心能否调用C字符串库或C套接字库等。

Modula-3语言也是一个重要影响。这是一种以欧洲为中心的语言,源于Pascal。对于像我这样的美国人来说,可能不太考虑Modula,但Guido显然在研究如何实现某些功能。Modula-3中有一些非常好的思想,他曾与Modula-3团队交流。self 作为第一个参数的概念,是在过程式语言之上构建面向对象机制的一种方法。这个 self 的概念灵感,正是来自Guido与Modula-3的互动。

总而言之,Python的设计是多种编程语言思想和实践融合的成果,最终形成了我们今天所熟知的强大而灵活的语言特性。

047:Guido van Rossum的灵感与历史

在本节课中,我们将跟随Python之父Guido van Rossum的讲述,了解Python面向对象设计思想的灵感来源与演变历史。我们将探讨从ABC语言到Modula-3,再到C++的影响,以及Python如何最终形成其独特的面向对象实现。


ABC语言:非面向对象的基础

上一节我们介绍了课程背景,本节中我们来看看Python设计思想的起点——ABC语言。ABC语言并非面向对象语言。

ABC语言拥有一组固定的数据类型。虽然这些数据类型是可组合的,例如,你可以拥有一个整数列表或一个字符串列表,并且它们共享列表上的操作,但ABC语言中不存在“类”的概念。用户无法定义类,也没有子类化的概念,无论是对于用户自定义类型还是内置类型都是如此。

ABC语言提供了一组非常便于使用的基础数据类型。这些类型为用户做了大量工作,但它们并非真正的面向对象。ABC语言甚至坚持只使用单一的数字类型,以避免处理整数、浮点数、有理数等复杂的类型层次结构。

C++的影响与自动引用计数的尝试

在开始设计Python时,面向对象编程是Guido van Rossum的核心关注点之一,而不仅仅是提供一组方便的基础数据类型。那么,这种面向对象的思维从何而来呢?

以下是Guido van Rossum接触面向对象语言的主要经历:

  • 熟悉C++:当时,C++可能是他唯一了解的面向对象语言。
  • 了解Simula:他拥有一本关于Simula(所有面向对象语言的鼻祖)的大部头书籍,但并未深入研读,也从未获得过Simula编译器。
  • C++编程经验:他编写过足够多的C++代码,甚至尝试过发明自动引用计数指针(类似于智能指针),以在C++编程中保持理智。

Guido van Rossum对引用计数非常熟悉,因为ABC语言的实现就是用C写的,并且所有东西都使用引用计数。这在ABC中运行良好,因为其数据类型不可能出现循环引用(没有可变数据类型,一个对象无法直接或间接地包含或引用自身)。

然而,在ABC的实现中,引用计数容易出错。团队经常需要处理内存泄漏或提前释放内存导致的崩溃问题,调试遗漏的引用计数增减操作非常困难。

在自学C++的过程中(大约在80年代中期),他尝试利用C++可以重载基本操作符的特性,构建了自动引用计数机制。但实验发现,这种自动机制并不理想。问题在于,如果手动管理引用计数,在将对象传递给函数时,若调用者仍持有引用,则函数内部无需增加引用计数。而他的自动引用计数实现会在每次传递参数时都增加引用计数,并在函数返回时减少,导致引用计数操作过于频繁且低效。因此,Python最终选择在C语言中手动管理引用计数,而非使用C++的自动机制。

Modula-3的关键启发:方法即函数指针

在Python实现面向对象的过程中,还有一个关键步骤。大约在1988年,Guido van Rossum在DEC SRC实习,接触到了Modula-3语言的设计者。

他从Modula-3(或Modula-2+)的文档中学到了一个核心概念。Modula-3并非完全的面向对象语言,但其设计包含了面向对象使用的关键部分,可以看作是对C++实现方式的一种反应。

在Modula-3中,如果你使用 object.method(arguments) 这样的语法,那么 object 必须是一个包含一系列函数指针的结构体类型。方法名就是该结构体中的字段(成员)。要创建一个“类”,你就定义一个包含一系列类型化函数指针的结构体。编译器的技巧在于,当它发现你正在调用这样的方法时,会自动将调用该方法的对象作为第一个参数插入到函数调用中。

这正是Python中显式 self 参数的来源。 Python最初复制了Modula-3的这一设计。

Python的早期实现与“class”关键字的引入

最初,Python在头五六个月里并不是面向对象的。其实现有一个概念:可以通过将一堆函数指针和一些其他信息放入一个标准结构体中来定义一个对象类型(Guido称之为“type”而非“object”),这本质上是在模拟C++创建对象或进行面向对象编程的方式。

最初,Python甚至没有提供用户层面的语法来定义这样的类型,类型系统只能通过编写C扩展来扩展。“class”关键字并不存在。

大约在项目开始五个月后,一位更熟悉C++的实习生提出了一个方案:添加一点语法,将其映射到实现时的结构体,就能让一切运行起来。于是,“class”关键字被引入。至此,Python已经拥有了一个可工作的解释器,包含数字、字符串、元组、列表、字典、函数等类型,并且类型在内部已经是“对象”。当时甚至已经可以查询对象的类型。

在那最初的六个月里,Guido van Rossum并未觉得自己在进行关于未来面向对象该如何定义的核心研究。他只是在拼凑一个不知将走向何方的语言实现。他是一名程序员,而非研究者,也没有博士学位。

设计取舍与后续演进

Guido van Rossum并不认为自己做出了深远的贡献,他认为Modula-3的设计者们才是更深入思考理论影响的人。他自己则乐于实现一些东西,即使在某些边缘情况下它可能完全无法正确工作。

一个典型的例子是循环引用导致的内存泄漏。在很长一段时间里(大约十年),由于Python使用引用计数且没有循环垃圾回收器,如果创建了循环引用并丢失了从外部指向该循环的最后指针,内存将无法挽回地泄漏。直到用户用实际案例说服开发团队这是一个真实存在的问题后,Python才最终加入了循环垃圾收集器。


本节课中,我们一起学习了Python面向对象设计思想的演变历程。从非面向对象的ABC语言基础,到尝试并放弃C++风格的自动引用计数,再到借鉴Modula-3将方法实现为结构体中的函数指针并引入显式 self 参数,最终通过实习生的贡献引入了“class”关键字。整个过程体现了Python务实的设计哲学:优先解决实际问题,在必要时接受设计上的取舍,并根据用户反馈和实际需求持续演进。

048:代码走查 - 探索Python字符串对象的引用计数机制

在本节课中,我们将通过代码走查,深入探讨Python早期版本中字符串对象实现的核心机制——引用计数。我们将分析一段模拟Python 1.0中字符串处理的C语言代码,理解如何通过引用计数来高效管理内存,允许多个变量指向同一字符串数据而无需复制。

概述与代码背景

我们分析的代码源自课程尾声部分,旨在对比早期C语言实现与Python 1.0(以及后来的Python 3.7)在字典、列表和字符串处理上的差异。本节课我们聚焦于字符串的实现。

这段代码的核心模式是使用分块字符数组来存储字符串。与基础实现相比,这里引入了一个关键概念,该概念源自ABC语言,并在早期Python版本中被Guido van Rossum所重视,即引用计数

引用计数的核心思想是:当进行赋值操作时,我们不必复制全部数据,而是可以复制一个指向数据的指针。然而,这要求我们谨慎管理引用计数,因为删除操作必须知道何时引用计数归零,才能安全释放内存。

代码结构与演示

我们先观察主函数中的演示逻辑,了解引用计数如何运作。

以下是主程序中的关键步骤:

  1. 创建一个新字符串并输出其状态。
  2. 向其追加字符 ‘H’,并再次输出。
  3. 向其追加字符串 “LO world”,并输出。
  4. 进行赋值操作 y = x,这是引入引用计数的关键部分。
  5. 删除变量 x
  6. 最后,删除变量 y

让我们运行这段代码,观察输出。在初始操作后,我们关注赋值语句之后的部分。当执行 string x = a_completely_new_string 时,程序会输出该字符串在内存中的位置。紧接着,在执行 string y = a_completely_new_string 后,会显示它位于相同的内存地址。这表明 xy 现在指向同一个字符串对象,并且该对象的引用计数增加了。

随后,在代码中我们调用 p1_str_del(x)。这个操作并不会立即释放字符串数据,而只是将引用计数减1。此时,变量 y 仍然有效。直到最后调用 p1_str_del(y),引用计数变为0,程序才真正释放底层数据。

这种机制使得我们可以复制引用而不复制数据,xy 指向同一数据且引用计数为2。释放 xy 时,只是递减引用计数,仅当计数归零时才真正回收内存。

核心函数剖析

上一节我们通过主程序看到了引用计数的效果,本节我们来深入看看实现这些功能的核心函数。

构造函数:p1_str_new

构造函数负责初始化一个新的字符串对象。

  • 它首先为对象结构体和数据缓冲区分配内存。
  • 将初始长度设为10,并在缓冲区末尾放置换行符和字符串终止符 \0
  • 关键的一步:将引用计数 refs 初始化为1。因为一旦创建,就假定会有一个变量引用它。

追加函数:p1_str_append

此函数用于向字符串追加内容,其逻辑与K&R书中的实现类似。

  • 如果当前空间不足,它会重新分配一个更大的内存块(每次增加10字节,即所谓的“分块”策略)。
  • 然后将新字符(或字符串)添加到末尾,并更新长度。
  • 最后确保字符串正确以 \0 终止。

赋值函数:p1_str_assign

这是实现引用计数机制的核心函数之一。

  • 它的作用是让另一个指针指向同一个字符串对象。
  • 函数内部非常简单:将对象的引用计数 self->refs 加1 (self->refs++)。
  • 然后返回指向同一对象的指针。
  • 在主程序中,我们通过 struct p1_str *y = p1_str_assign(x); 来调用它,这等效于 y = x,但显式地记录了引用计数的增加,以确保内存管理的正确性。

删除函数:p1_str_del

这是引用计数机制得以闭环的关键函数。

  • 当调用删除函数(如 p1_str_del(x))时,它首先检查引用计数。
  • 如果引用计数 self->refs 大于1,说明还有其他变量引用此数据。此时,它仅执行 self->refs-- 然后返回,并不释放任何内存
  • 只有当引用计数减为1后再次被删除,使得计数变为0时,函数才会执行真正的释放操作:先释放字符串数据 self->data,再释放对象结构体 self 本身。

通过这种机制,对象内部自动处理了多引用情况下的内存生命周期管理。

引用计数的重要性与总结

本节课我们一起分析了模拟Python字符串引用计量的C代码。其核心在于,通过 p1_str_assign 增加引用计数,通过 p1_str_del 减少引用计数,并在计数归零时释放内存。

这种对引用计数的“执着”源于早期计算环境中内存资源的稀缺性。引用计数机制使得程序能够创建多个指向同一字符串(尤其是常量字符串)的引用,而无需为每个引用分配重复的内存空间,从而极大地节省了内存。尽管在后续展示其他数据结构的样例代码中可能不再包含引用计数,但理解它是理解Python早期实现及其内存管理哲学的重要基础。

总结来说,引用计数是Python 1.0实现中不可或缺的一部分,它优雅地解决了共享数据时的内存管理难题。

049:代码走查 - 构建Python 0.0.9列表实现

在本节课中,我们将通过代码走查,对比两种不同的列表实现方式。我们将看到Python早期版本中Guido van Rossum如何实现列表,以及我在讲解《C程序设计语言》第六章时如何实现列表。核心在于理解数组链表这两种数据结构的差异。

上一节我们介绍了本次对比的背景,本节中我们来看看具体的代码实现。

🏗️ 数据结构对比

Python 0.0.9中的列表对象是一个指针数组。而《C程序设计语言》第六章中实现的列表是一个链表

以下是两种数据结构的关键定义:

  • Python 0.0.9 列表 (P1list):其核心是一个指向字符指针的数组。

    struct P1list {
        char **items; // 指向指针数组的指针
        int length;   // 已使用的元素数量
        int alloc;    // 数组的总容量
    };
    

    在构造函数中,会初始化一个容量为2的数组,并将 length 设为0。

  • K&R 风格链表 (KRlist):这是一个经典的链表结构,包含头尾指针和计数器。

    struct KRlist {
        struct node *head;
        struct node *tail;
        int count;
    };
    

    每个节点 (node) 包含一个字符串指针和一个指向下一个节点的指针。

数组的实现比链表更简单。链表需要管理节点、头尾指针和连接关系,而数组只需要管理一个连续的内存块和两个计数器。

🔧 接口与抽象

尽管底层实现不同,但两种列表为调用者提供了完全相同的抽象接口。这意味着使用列表的代码(主函数)几乎可以保持不变。

以下是主函数中展示的通用操作:

  • 创建列表 (new)
  • 添加元素 (append)
  • 打印列表 (print)
  • 获取长度 (length)
  • 查找元素 (index)
  • 删除列表 (delete)

调用者无需关心底层是数组还是链表,这体现了接口抽象的价值。

📝 append 操作详解

append 操作是两种实现差异最明显的地方。让我们看看它们如何处理添加新元素。

链表 (KRlist_append) 的实现逻辑如下:

  1. 创建一个新节点。
  2. 如果链表为空,将新节点设为头节点。
  3. 如果链表不为空,将新节点链接到尾节点之后。
  4. 更新尾指针为新节点。
  5. 增加计数器。

数组 (P1list_append) 的实现则更为直接:

void P1list_append(struct P1list *self, char *s) {
    // 1. 检查容量
    if (self->length >= self->alloc) {
        // 容量不足,进行扩容
        self->alloc += 10; // 每次增加10个位置的容量
        self->items = (char **)realloc(self->items, self->alloc * sizeof(char *));
    }
    // 2. 保存字符串
    char *saved = strdup(s);
    // 3. 将新字符串指针放入数组末尾
    self->items[self->length] = saved;
    // 4. 更新已使用长度
    self->length++;
}

关键在于 realloc 函数。当数组空间不足时,realloc 会分配一块更大的新内存,自动将旧数据复制过去,并返回新数组的指针。这避免了手动复制数据的复杂代码。

Guido van Rossum 选择相信并利用 realloc 的可靠性,从而获得了简洁的数组实现。而传统的链表教学则倾向于避免频繁的内存重新分配。

🖨️ 遍历与打印

遍历操作的代码清晰地反映了底层数据结构的差异。

以下是两种实现的打印函数核心循环:

  • 数组遍历 (P1list_print):使用简单的 for 循环,通过索引访问。

    for (i = 0; i < self->length; i++) {
        printf(“%s\n”, self->items[i]);
    }
    
  • 链表遍历 (KRlist_print):使用指针跟随 next 指针进行遍历。

    for (cur = self->head; cur != NULL; cur = cur->next) {
        printf(“%s\n”, cur->text);
    }
    

数组的遍历代码对于初学者来说更直观易懂,它就是最基本的循环结构。链表的遍历则需要理解指针“跟随”下一个节点的概念,虽然对于有经验者是一种惯用写法,但学习曲线更陡峭。

🧹 删除操作

删除操作也需要释放所有已分配的内存。

数组列表的删除 (P1list_delete) 逻辑清晰:

  1. 循环遍历数组,释放每个字符串元素 (free(self->items[i]))。
  2. 释放存储指针的数组本身 (free(self->items))。
  3. 释放列表结构体 (free(self))。

链表的删除 (KRlist_delete) 则需要注意顺序:

  1. 循环遍历链表,释放每个节点存储的字符串。
  2. 在释放当前节点的字符串后,需要先保存下一个节点的地址,再释放当前节点本身,以确保遍历能继续。

数组版本的删除再次因其线性和可预测性而显得更简单。

💎 总结

本节课中我们一起学习了两种列表实现方式的详细对比。

  • Python 0.0.9 的列表 使用动态数组实现,依赖 realloc 进行扩容。其代码(如遍历、删除)更简单、直观,易于初学者理解和编写。
  • K&R 教学中的列表 使用链表实现。它更传统,是数据结构的经典教学案例,但涉及更多的指针操作和顺序逻辑,复杂度更高。

这次对比的核心启示在于:实现选择受到开发者对底层工具(如 realloc)的信任度和熟悉度影响。Guido 选择了让实现更简单的数组方案,而链表则是许多计算机科学课程的首选教学工具。

建议你将 P1list.cKRlist.c 的代码并排打开,仔细比较。思考在编写、调试和理解这两个版本时所需的复杂度和知识储备。这能帮助你更深入地理解数据结构的抽象与具体实现之间的关系。

050:代码走查-构建Python-0.0.9字典实现

在本节课中,我们将通过代码走查,对比分析K&R教材中实现字典的方法与Python创始人Guido van Rossum在Python 1.0中实现字典的方法。我们将重点关注数据结构、哈希函数、冲突解决和动态扩容等核心概念。


数据结构对比

上一节我们介绍了课程目标,本节中我们来看看两种实现方式在数据结构上的根本区别。

在我的K&R风格实现(kr_dict)中,字典是一个数组加链表的结构。我定义了一个固定大小的数组(例如4个桶),每个桶指向一个链表的头节点。

// K&R 风格字典节点
struct dict_node {
    char *key;
    int value; // 为简化,值设为整数
    struct dict_node *next; // 指向链表下一个节点
};

// K&R 风格字典结构
struct kr_dict {
    int num_buckets;
    int count;
    struct dict_node **heads; // 桶数组,每个元素是链表头指针
    struct dict_node **tails; // 链表尾指针,便于追加
};

而在Guido的Python 1.0实现(p1_dict)中,字典是一个一维数组,数组的每个元素直接是一个键值对节点。节点中的“空”状态用NULL指针标记。

// Python 1.0 风格字典节点
struct dict_node {
    char *key;   // 字符串键
    char *value; // 字符串值
};

// Python 1.0 风格字典结构
struct p1_dict {
    int length;   // 已使用的项目数
    int alloc;    // 数组总容量
    struct dict_node *items; // 指向dict_node数组的指针
};

Python 1.0的实现与它的列表实现非常相似,列表也是一个length、一个alloc和一个指向数据数组的指针。这种设计体现了字典和列表在Guido心中的某种对等性。


构造函数与初始化

理解了基本结构后,我们来看看如何创建和初始化一个字典对象。

p1_dict_new函数中,我们进行以下操作:

  1. 为字典结构体分配内存。
  2. length初始化为0,因为字典为空。
  3. alloc初始化为2,这是一个较小的初始容量,便于我们测试和调试扩容逻辑。
  4. items数组分配内存,大小为 2 * sizeof(struct dict_node)
  5. 将数组中每个节点的keyvalue指针都设为NULL,以此标记所有槽位都是“空”的。

以下是初始化后的状态:

  • length = 0
  • alloc = 2
  • items是一个包含两个dict_node的数组,它们的keyvalue均为NULL

核心操作:插入(Put)

字典的核心功能是插入键值对。以下是p1_dict_put函数的主要步骤。

首先,我们需要找到键key应该存放或已经存放的位置。这通过一个辅助函数p1_dict_find完成。

查找函数 p1_dict_find

这个函数负责根据键找到数组中对应的槽位。它实现了开放寻址法线性探测

  1. 计算哈希桶:使用一个简单的哈希函数get_bucket,根据键的字符串计算出一个哈希值,然后对数组容量alloc取模,得到初始索引bucket
    // 简化示例哈希函数
    int get_bucket(char *key, int alloc) {
        unsigned int hash = 0;
        for (char *p = key; *p != '\0'; p++) {
            hash = (hash << 5) ^ *p; // 左移5位并异或字符
        }
        return hash % alloc;
    }
    
  2. 线性探测解决冲突:从bucket位置开始,线性地向后遍历数组(到达末尾后回到开头)。对于每个检查的位置i
    • 如果items[i].key == NULL,说明找到了一个空槽位。返回该槽位的地址。
    • 如果items[i].key不为NULL且与目标键key匹配(strcmp相等),说明找到了已存在的键。返回该槽位的地址。
    • 如果items[i].key不为NULL但不匹配,则继续检查下一个位置(i+1)。
  3. 查找失败:如果遍历了整个数组(检查了alloc次)都没有找到空槽或匹配的键,则查找失败。在正确的实现中,这通常意味着字典太满(超过负载因子),需要扩容。这里我们打印错误信息。

处理插入逻辑

回到p1_dict_put,根据find返回的结果old(指向找到的槽位),我们分情况处理:

  1. 键已存在(替换值):如果old不为NULLold->key也不为NULL,说明找到了一个已存在的键。

    • 释放旧值old->value所占用的内存。
    • 为新值value分配内存并复制字符串。
    • old->value指向新分配的内存。
    • 完成操作,无需改变length
  2. 键不存在(插入新键值对):如果old指向一个空槽位(old->key == NULL)。

    • 检查是否需要扩容:如果当前已使用量length大于等于容量的70%(length >= alloc * 0.7),则调用_rehash函数进行扩容。扩容后需要重新调用find查找key的位置。
    • 执行插入:为keyvalue分配内存并复制字符串。
    • old->keyold->value指向新分配的内存。
    • length加1。

动态扩容与重哈希

当字典的负载因子(已使用量/总容量)过高时,查找效率会下降,冲突增多。因此需要在插入新元素前检查并扩容。

_rehash函数的工作流程如下:

  1. 保存旧的items数组指针和alloc容量。
  2. 将新的容量alloc加倍(例如从2变为4)。
  3. 为新的items数组分配内存,大小为 新alloc * sizeof(struct dict_node)
  4. 将新数组的所有槽位的keyvalue初始化为NULL
  5. 关键步骤:重哈希:遍历旧的items数组。对于每一个非空槽位(key != NULL):
    • 以新数组(self->items此时已指向新数组)为基准,调用find函数,找到这个(key, value)在新数组中应该存放的位置。
    • 将旧节点中的keyvalue指针(注意不是字符串内容)复制到新数组找到的槽位中。这是一个浅拷贝,避免了字符串的重复分配和复制。
  6. 释放旧的items数组内存(注意,不释放keyvalue指向的字符串内存)。

重要影响:重哈希后,键值对在新数组中的位置可能发生变化,因为哈希值取模的基数(alloc)变了。这意味着字典的迭代顺序在扩容后可能改变。


与K&R实现的对比

现在,让我们将Python 1.0的实现与我基于K&R教材编写的实现进行对比。

在K&R的链地址法实现中:

  • 冲突解决:哈希冲突通过链表解决。多个哈希到同一桶的键值对会形成一条链表。
  • 扩容:理论上,当链表过长时也需要扩容(增加桶的数量并重哈希所有元素到新链表)。但在我的示例代码中,为了简化,没有实现动态扩容。
  • 插入逻辑:插入时,先找到桶,然后遍历链表。如果找到键则更新值;否则,将新节点追加到链表末尾。不需要线性探测。
  • 内存:每个节点都需要额外的next指针开销。

Python 1.0的开放寻址法实现:

  • 冲突解决:哈希冲突通过线性探测在数组内部解决。
  • 扩容:有明确的负载因子检查(70%)和完整的重哈希逻辑,是实现的一部分。
  • 数据局部性:所有数据存储在一个连续的数组中,理论上对CPU缓存更友好。
  • 内存:没有额外的链表指针开销,但需要维护一个可能稀疏的数组。

总结

本节课中我们一起学习了Python 1.0字典实现的代码走查。

我们深入分析了其核心数据结构——一个通过NULL标记空位的键值对数组。我们探讨了如何使用哈希函数和线性探测来解决键的定位和冲突问题。重点理解了动态扩容的机制,即在插入新元素前检查负载因子,并通过“重哈希”过程将所有现有元素重新放置到新的、更大的数组中,这个过程会导致键的存储位置发生变化。

最后,我们对比了这种开放寻址法与K&R教材中使用的链地址法在数据结构、冲突解决和扩容策略上的异同。Python 1.0的实现体现了设计上的一致性(与列表类似)和对性能的初步考量,为后续Python版本的字典优化奠定了基础。

051:代码走查 - 构建Python 3.7字典实现

概述

在本节课中,我们将学习Python 3.7版本中字典数据结构的内部实现。我们将对比早期版本(Python 0.01至3.6)的字典实现,重点分析Python 3.7如何通过改变数据结构来提升效率并维持插入顺序。

Python 3.7字典解决的问题

上一节我们介绍了Python早期版本的字典实现。本节中,我们来看看Python 3.7字典旨在解决的核心问题。

在Python 0.01的字典实现中,每个键值对条目(entry)存储了指向键的指针、指向值的指针以及哈希值。在64位系统中,这通常占用24字节。字典的负载因子(load factor)不允许超过0.7,这意味着数组中有30%的空间必须为空。这部分浪费的空间是固定的,无法避免。

Python 3.7版本改变了这一结构。它将实际的键值对数据存储在一个线性的指针数组中,而将哈希查找和冲突解决的功能分离到一个独立的、更小的整数索引数组中。

新的数据结构设计

理解了问题所在后,我们现在深入看看Python 3.7字典的具体数据结构设计。

在Python 3.7的实现中:

  • items 数组:一个线性数组,存储指向实际键值对结构(包含键、值、哈希等)的指针。
  • index 数组:一个独立的整数数组,用于哈希查找。其长度通常是 items 数组的两倍,从而将哈希表的负载因子控制在0.5以下。

这种设计带来了几个好处:

  1. 减少内存浪费index 数组仅存储整数,比存储完整条目指针更节省空间。
  2. 维持插入顺序:由于 items 是一个线性追加的数组,自然保持了键值对的插入顺序。
  3. 简化扩容逻辑items 的扩容变得与Python列表(list)的扩容非常相似。

代码实现解析:初始化与查找

了解了整体结构后,我们通过代码来具体看看它是如何工作的。首先从构造函数和查找函数开始。

以下是字典对象初始化的简化表示:

struct P3Dict {
    struct DNode **items; // 指向键值对指针数组的指针
    int *index;           // 哈希索引数组
    int length;           // 已使用的条目数
    int alloc;            // items数组的分配容量
};

在构造函数 P3Dict_new 中,我们分配字典对象、初始大小的 items 数组以及两倍于其大小的 index 数组。index 数组的所有位置初始化为 -1,表示该槽位为空闲。

查找函数 P3Dict_find 的核心逻辑是在 index 数组中定位一个键。它计算哈希值,然后在 index 数组中进行线性探测(linear probing),直到找到目标键或一个空闲槽位(值为 -1)。如果找到键,则返回其在 index 数组中的位置;如果找到空闲槽,也返回该位置,表示可以在此插入新键。

插入操作与动态扩容

掌握了查找机制后,我们来看插入操作 P3Dict_put 是如何利用查找结果并处理数组扩容的。

插入操作的步骤如下:

  1. 调用 P3Dict_find 查找键。
  2. 如果找到(返回位置不是 -1),则替换对应的值。
  3. 如果没找到,则需要插入新键值对。此时检查 items 数组是否已满(length >= alloc)。
  4. 如果 items 已满,则需要进行扩容。这与Python列表的扩容模式一致:
    • 重新分配一个更大的 items 数组(例如,容量翻倍)。
    • 同时,分配一个新的、更大的 index 数组(大小仍为 items 新容量的两倍),并全部初始化为 -1
    • 遍历现有的 items 数组,对每个键值对重新调用 P3Dict_find,在新的 index 数组中重建哈希索引。
  5. 扩容完成后(或原本就不需要扩容),执行插入:
    • 将新的键和值复制到 items 数组的 length 位置。
    • 调用 P3Dict_find 获取新键在 index 数组中的应存位置 pos
    • 执行赋值:self->index[pos] = self->length;。这行代码是连接哈希索引和线性存储的关键,它记录了在 index 数组的 pos 位置,对应的是 items 数组的第 length 个元素。
    • 增加 length 计数。

总结

本节课中我们一起学习了Python 3.7字典的实现原理。通过将数据存储(items)与哈希查找(index)分离,新设计显著减少了内存浪费,并自然地维持了插入顺序。其扩容机制也变得更为清晰和高效,类似于列表的扩容。与早期版本复杂的再哈希代码相比,Python 3.7的字典实现展示了优雅而高效的数据结构设计。

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