查看: 42|回复: 26

[讨论] 聊聊编程范式

[复制链接]

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

发表于 昨天 13:27 | 显示全部楼层 |阅读模式
大概是去年还是前年就开始写的一些东西,一直没写完,最近写得差不多了,姑且先发一些。

编程范式是一个相当空泛的概念。为什么这么说呢?就我个人的学习经历而言,这方面的内容应该是相当经验性和实践性的,任何初学者都很难直接从理论出发理解这些东西。我几年前看过很多讲面向对象的资料,最后看了都跟没看一样,面向对象的这些东西都是我后来在实操过程中才慢慢领略到的。甚至就包括我现在,我也看了很多函数式编程,MVVM,DDD 之类的东西,最后看了还是跟没看一样。问题在于:概念太多,概念带来的可能性也太多;此外,随着各种编程范式这么多年的发展,现如今的主流编程风格早已是各种范式的融合体,这就使得识别和学习模式更加困难。当然,还有个问题可能是学习资料,很多资料都会不加解释地使用各种概念,这样实在是难以理解。

我自然也没信心写出什么好的学习资料出来,姑且是想作一个自我总结。如果能帮到你,那也挺好。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:28 | 显示全部楼层
0. 游戏程序的起点

抛开一切不谈,游戏就是一个无限循环的程序。中学信息课的编程或者大学通识课的编程通常写的是另一种命令行程序,这种程序往往只有几个输入,然后程序运行完就结束了。而游戏往往是一个几乎无时无刻不在运行的程序——因为它是动态的,需要时时刻刻进行更新。

首先要明确一点:程序执行任何一条命令,都是需要时间的。你让程序帮你算一下 1+1,时间可能忽略不计;但你让程序帮你算一万个线性方程组,可能就要等好一阵了。

其次还需要引入一个概念:FPS,即 Frames Per Second。大众对这个词的认知,往往局限于这个值越高游戏看起来就越流畅,配置要求也越高。所谓 60 FPS,准确含义是:游戏程序每秒最多执行六十次命令。你最终看到的每一帧画面,都是预先设置好的命令全部执行完毕之后的结果。刚才已经提到,程序执行命令是需要时间的,那么执行每一帧预设的游戏命令需要多少时间呢?这当然取决于机器性能,但不管怎么样,我们总是希望这个时间小于 1/60s,否则我们的游戏就卡了,也就是所谓的掉帧。

当然,FPS 更大的意义在于提供了时间方面的直观模拟。对于游戏来说,我们往往希望它是直观的,符合时间运动规律的。在 10m 的空中放一个小球进行自由落体运动,你的高中物理知识,或者纯粹的物理引擎,可以准确地计算出每一个时刻这个小球的位置。而对于游戏程序来说,它需要做的是把这个小球自由落体的过程动态地展示出来,且这个过程需要与现实时间的流动相符。FPS 的意义就此体现出来,因为它保证了,在机器性能足够的情况下,每一帧的时间差一定是 1/60s,这是我们进行时间流动模拟不可或缺的计算依据。

不过,你可以观察到有的游戏并不限制帧率,换句话说就是采用了毫不停歇的循环。这些游戏自然也需要处理时间方面的直观模拟,他们的办法是估算每一帧花费的时间。需要注意的是,这个估算值往往不准确,且十分不稳定,这也是为什么大部分游戏仍然采用了固定的 FPS,尤其是针对物理引擎这部分的模拟。

就是这样了,游戏程序是一个无限的循环,知道这一点就够了。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:28 | 显示全部楼层
1. 面向过程

几乎任何编程初学者,第一次接触的编程范式便是面向过程。在我看来,这个范式最大的特点就是没有任何范式可言。程序什么时候该做什么,怎么样去做,一切都按照最基本的想法进行组织。

至于面向过程的名字是怎么来的,大概是这样:首先,做一个程序,最终都是让它去完成某一个任务。做这样的程序,最简单直接的考虑办法是:完成这个任务需要怎样的步骤?换句话说,完成这个任务的过程是怎么样的?这确实是最简单和基本的考量方式,非常容易被理解和接受。

游戏程序需要的完成的是什么任务呢,往往是这样的:

1. 监听玩家输入
2. 处理游戏逻辑
3. 渲染游戏画面
4. 解码游戏音频
5. 等待 FPS 限制,并最终输出画面和音频

大家在用游戏引擎的时候,1 3 4 5 都已经被引擎考虑进去了,我们需要做的仅仅是第 2 步而已。

注:实际情况往往并不遵循以上的串行模型,例如,解码音频通常放到另一个线程完成,以保证不拖慢游戏。关于多线程和并行架构,我未来可能会再专门写一篇文章谈谈。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:29 | 显示全部楼层
1.1 过程意识

显然,上述五个步骤,除了 3 4 可能可以对调之外,没有什么商量余地。不过,游戏程序的初学者,尤其是游戏引擎用户,很容易没有过程意识。提一个典型问题,这个问题我感觉我已经解答过数不清多少次了:

- 设置物件 B 的坐标,试图使其与物件 A 同步
- 物件 A 执行其运动相关逻辑

问题:为什么物件 B 始终比物件 A 慢一拍?留给读者思考。

对于从游戏引擎用户起步的游戏程序初学者来说,没有过程意识也挺正常,不必沮丧,这当然也不能怪谁。还记得想当初我看到 RE 还是什么地方的事件,总之就是有一行关闭了某个 flag,但是不远处的一行又打开了这个 flag。我当时很不解地去问了某名淡坑吧友,他告诉我这是让这个 flag 处于既关又开状态。(我:???)

处理面向过程任务时,一定要对游戏程序每一帧内从先到后做的事情,整体有一个概念。完成任务的步骤显然不能随意地调换,每一行代码都有它理应处在的位置。从个人经验来谈,一个开发者在开发过程中对步骤的组织,能够反映出这个人的开发习惯和水准。

点评

既关又开状态笑死,波 粒 二 象 性  发表于 昨天 15:33
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:29 | 显示全部楼层
1.2 过程冗长

对于我们做一些数值实验,实现一些简单的算法程序来说,面向过程足够简单好用了,不会有什么问题,因为过程本身就非常短。而对于游戏来说,面向过程最明显的问题就是过程的冗长。

实在是太长了,看着 w10e 主事件 2k 行,我想你大概就能体会到一些问题——你会感到无从下手。游戏逻辑的执行顺序是至关重要的,调换任意两个命令的顺序可能会带来完全不同的结果。如果整个游戏的逻辑代码都是你敲的,那还行,未来如果你的游戏需要加什么新功能,起码你还有可能记得住你应该在什么地方插入这些新功能的代码才不会出问题。但是,当你拿到一个不是你敲的面向过程程序之后,问题就出现了:你极有可能需要通读一遍整个逻辑框架,才能在此基础上添加你自己需要的东西。

毫无疑问,这对于团队开发来讲,是一件效率极为低下的事情;就算是你个人开发,区区一个 w10e 便有 2k 行事件,更复杂的游戏逻辑你怎么指望你记得住这些冗长的过程细节?写文档吗?文档也会越写越长,解决不了问题。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:29 | 显示全部楼层
本帖最后由 dasasdhba 于 2026-4-18 13:44 编辑

2. 面向对象

RE 采用了 CTF 的分组功能,其实是带来了一点面向对象的设计思想。这些分组功能带来的好处,本质上是代码复用,以及实现封装——对于用户而言,你无需关心分组实现的具体技术细节,只需要懂怎么使用即可。面向对象至今仍旧是游戏开发乃至软件开发的主流编程范式,它是一种程序设计思想,能够帮助你更好地组织程序,能够帮助团队更好地合作开发。

问题在于,面向对象与其说是一种设计思想,不如说是一大堆设计思想的总和。这也是为什么很多初学者会感到面向对象入门困难——概念实在是太多了,而且如果没有实际项目经验的话,也完全难以理解和接受。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:29 | 显示全部楼层
2.1 一切源于对象

面向对象范式的名称,主要源于“对象”的概念本身。准确地说,我们定义了一个所谓“对象”的概念,并以此为基础发展了一整套程序设计体系。

如果是我,我会简单地将对象定义为若干属性和函数的集合,其中属性指的是特定的数据类型及其数据,函数指的是接收特定参数并执行特定逻辑的程序命令集(代码片段)。这里的数据类型,可以是整数,小数(浮点数),字符(串),甚至可以是其他对象(所在的类)。

我的理解是,对象是一种对于我们日常认知上的,所谓客观实体的一种抽象描述,世间万物都可以抽象为这个概念。比如,我是人类对象,我拥有三维空间中的位置属性,我也拥有各个器官对象相应的属性,我还拥有日常生活各种行为逻辑方面的函数。

引入对象的概念是为了帮助我们更好地进行功能整合与思考。从哲学上考量,整个世界上所有的事物,最终都可以抽象为各种在不断变化着的数据;编程当然也可以这么干,或者说其实本质上只能这么干。从这个角度来说,对象这个概念与其说是对客观实体的一种抽象,不如说是对纯粹数据集合的一种具象。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:30 | 显示全部楼层
2.2 继承与代码复用

生物学中对生物的分类,大概是面向对象继承思想最直观的一个写照。所有的生物中,动物是一个大类,所有属于动物类的生物都会具有一些共性;所有的动物中,哺乳动物又是一个大类,所有属于哺乳动物的动物也会有一些共性……事实上,不管是生物学领域,还是别的什么领域,我们总是能将各种事物进行分类,并且最终得到一个树状结构。(如果不知道什么叫树状结构,可以简单观察一下 PC 上的文件夹是怎样的结构。)

而所谓“类”,便是面向对象编程范式当中,我们进行对象设计的基本手段。通过规定怎样的类应该有怎样的属性和函数,我们就完成了“一类”对象的设计。在此基础上,我们还可以作进一步“分类”,也就是所谓的“继承”。回到生物分类的例子,假定我已经编写完毕“动物类”的代码,接下来我们要来实现“哺乳动物类”;而哺乳动物首先是动物,所以我们得先把动物类的代码复制粘贴一份。嗯,就是复制粘贴,但我们没必要真的去复制粘贴,你只需要指定:哺乳动物类“继承”动物类,那么这个复制粘贴的活就完成了。

好吧,这跟我们直接去复制粘贴有什么区别呢?试想,你手里并没有任何面向对象的工具,你复制粘贴了一百份动物类的代码,在此基础上实现了鱼类,牛类,羊类……直到有一天,你发现你最开始复制粘贴的那一百份代码有一处 bug,那么恭喜你,你中大奖了——改一百次吧,你没有任何办法。

通过合理的继承设计,我们便能够实现代码功能的复用,这绝不仅仅是省得复制粘贴那么简单,这实质上大大提高了程序项目的可维护性和可扩展性。这也是为什么我们长期以来不鼓励采用 w10e 引擎作进一步开发,因为这个引擎就是把所有敌人运动,敌人受伤,敌人攻击玩家,全部复制粘贴了一遍,完全不具备代码复用性。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:31 | 显示全部楼层
2.3 组合优于继承

在实践过程中,人们很快就发现,比起通过继承进行分类,组合也能达到几乎同样的代码复用效果,而且很多时候往往更好用。CTF 中每类 Active 可以同时属于多个分组,这就是典型的组合优于继承思想。事实上,很多东西通常都不是单一类别的,比如鲸鱼既是哺乳动物,又是海洋动物,你是做不到用单一的继承树来描述的。因此,比起通过继承直接定义一个对象是什么,不如通过给它设计一些合适的成员,来体现它有什么。鲸鱼类可以既有可以体现哺乳动物特征的数据类成员,也有体现海洋动物的数据类成员,这样就更加灵活。

放到今天的视角来看,继承的设计更像是历史遗留产物;其实,组合优于继承的思想早已深得人心,但出于兼容性的考量,老的东西我们没法破坏掉。事实上,很多新兴的编程语言都完全抛弃了继承设计,转用纯粹的组合 + 接口模式。这里,接口就是纯粹的体现一个对象“有什么”的约定,天然适合组合设计。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:31 | 显示全部楼层
2.4 符合阅读习惯的封装

面向对象的另一大重要作用,就是能够通过恰到好处的封装,让你能够将业务逻辑写得像自然语言描述的逻辑一样流畅。一个极端的观点认为,在优秀的封装设计加持下,一长段没有任何注释的代码也能具备相当舒适的可读性。我非常赞同这种观点。(我也很少写注释)

如何做到呢?这就要在对象设计上面下功夫。你需要明确每一类对象的功能,给它们及其成员属性和函数起一些恰到好处的名字。将复杂任务拆成简单零散的功能块,简单到你一看就能知道这些对象是做什么的。到这一步,理想情况下,你用这些对象来组织逻辑的时候,事情就会变得贴合自然语言。就算到后期维护的时候,反正每一个对象的职责你一目了然,谁出了问题改谁也就不是什么难事。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:31 | 显示全部楼层
2.5 过程的封装

合理的面向对象架构设计,可以减轻过程依赖的认知负担。正确的过程依旧重要,但通过任务和模块的拆解,可以将大大小小的过程封装起来,这就使得我们在编写逻辑的时候,只需要关注局部的过程,而不必非得对全局的过程有着完全的把握了。

如果不进行对象设计的封装,做一个纯粹的面向过程的程序,那么添加新功能的时候,你需要清楚地知道代码应该插入到什么位置;如果有了好的封装,你总是可以继承物理类来实现物理逻辑,继承渲染类来实现渲染逻辑。至于这些逻辑会插在什么位置,那是控制这些类的更高层级的对象需要考虑的事情,除了一些极端的情况之外,我们编写局部逻辑通常是不需要考虑的。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:31 | 显示全部楼层
2.6 为什么耦合是件坏事

讨论面向对象设计,耦合算是一个绕不开的概念;也正是从这里开始,面向对象的入门开始变得困难了——缺乏实际项目经验的情况下,根本领会不到耦合的恐怖之处,又何谈对其能有多深刻的理解?到这一步,我也很难保证初学者能够理解了,我只能说我尽力而为;大部分教材都做不到地方,还是别指望我能做到了。

我们称两个系统是独立的,若它们毫不相干,互相之间完全没有任何依赖关系;否则,我们就称这两个系统是耦合的。好吧,概念本身非常简单易懂,但令人困惑的点马上就来了——游戏程序中,几乎不可能不存在耦合关系,但与此同时,我们通常认为耦合是件坏事。耦合不可避免,因为程序要运作,许多不同的对象之间就必须相互交流协作,那么耦合坏事究竟坏在哪了?

我的观点,本质上,耦合会造成复用程度降低。

你正在开发类 A,这个类 80% 的内容可以独立完成,但是剩下 20% 内容依赖类 B。你懒得想太多,耦合上也就耦合上了。但是有一天,你突然发现你要开发类 C,这个类 C 前 80% 的内容与类 A 几乎一致,只是剩下 20% 的内容需要耦合类 D 而不是类 B。你很懊恼,当初开发类 A 的时候要是多花点心思解耦出来就好了。于是你为 A/C 类重构了一个基类作为补救。这只是最理想的情况,对于很多新手开发者来说,他们很可能就会把那 80% 的内容给复制粘贴几十次,导致后面想改也手足无措。

出现这种情况也间接表明上一节中的对象设计本身没做好,毕竟在理想情况下,每一类对象应该保持简单,各司其职,上面提到的问题不应该存在。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:32 | 显示全部楼层
2.7 对抗耦合的设计

为了解决耦合带来的灾难,人们想了很多的办法,这里挑两个常用的讲一讲。

2.7.1 依赖注入

假设系统 A 需要与系统 B 协同才能工作,比起直接显式地设置互相之间的引用,更合理的方式是将本质的依赖项提取出来。举个例子,飞乌龟需要面朝马里奥,因而需要马里奥的引用,这很正常;但是这样做很蠢,为什么?因为飞龟面朝马里奥,本质上需要的只是马里奥的坐标而已,你何必将整个马里奥的引用都交给飞乌龟呢?事实上,两个系统需要协同工作的时候,这里边真正依赖的数据,本质上往往并不多;更何况,就算两个系统之间的依赖项确实非常多、非常紧密,那你不妨问问自己,你何必将它们作为两个系统呢?为什么不合并呢?

总之,你完全可以把马里奥的坐标,作为设置飞乌龟朝向的逻辑函数的一个参数,而非直接在飞乌龟的逻辑里面引用整个马里奥对象。当然,要想获得马里奥的坐标,最终仍然需要获取马里奥的引用,但这部分内容是不必作为飞乌龟逻辑函数的一部分的。事实上,获取马里奥位置,并以此正确调用飞乌龟的函数,这个过程正是所谓的依赖注入。这个注入的过程姑且算是很无聊的工作,总是需要做,但没有什么维护的成本和价值,做这种工作的代码通常被称作 glue code。有很多工具和手段可以减少这类重复性 glue 的工作量,无论是自己做一些自动化的脚本,还是游戏引擎编辑器提供的一些功能,都可以减轻一些工作量。

对于一般情况来说,我们通常会将本质的依赖项提取出来,作为所谓的接口,这样一来,我们的系统可以与所有实现了接口的其他系统相协调,这就大大提高了灵活性。

2.7.2 观察者模式

游戏里面经常出现的一类需求是:某事发生时,执行某个事件。显然,将执行某个事件的代码直接插入到某事发生的代码,会直接导致耦合。例如,要实现玩家跳跃就会切换状态是开关砖,最简单的方法当然是找到玩家跳跃的代码,在那里加上切换的逻辑,这当然就将玩家代码和这类开关砖耦合了。

解决方案是,马里奥在执行跳跃代码之后,紧接着发布一条消息,告诉其他对象:我刚刚跳跃了!这样,开关砖如果收到了消息,就可以执行切换,从而避免了与马里奥跳跃逻辑的直接耦合。

在现代编程语言中,用于这类观察者模式的开发工具通常都有现成的。以 C# 为例,具体的运作方式如下:

1. 你可以给对象声明一类特殊的成员,名为“事件”
2. 你可以在任何时候发起这个事件,并传之以特定的数据 a;
3. 你可以用以 a 类型为参数的函数对事件进行“订阅”:当事件发生时,将 a 传入此函数并执行

由此可见,观察者模式跟依赖注入本质上是一回事,只是这里依赖的是一个“事件”。因此,我们依旧需要一定的 glue code;借助其他工具同样可行,Godot 的信号编辑器就是一个很好的例子。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:32 | 显示全部楼层
2.8 过度设计的误区

合理的架构设计可以降低耦合,提高代码复用,最终带来较高的可维护性和开发效率。然而,架构设计本身也是开发成本的一部分,这项工作并不容易,通常需要耗费更多的时间,带来的好处也只会在未来体现。显然,为整个游戏都不太可能会出现的解耦需求去做相应的架构设计,是绝对不划算的。

很多时候,你也许可以凭借自己的经验去判断哪些地方需要做架构设计,这点没有什么理论依据可循,具体情况具体分析。但是,游戏需求错综复杂,总会出现拿不准的情况,这时,更推荐的做法是首先不考虑任何架构设计,先管实现;等到未来发现耦合可能会带来危害的时候(比如你已经复制粘贴一段代码三次了),那么就该考虑重构了。所谓重构,其实就是重新设计架构,使得原本的逻辑保持不变,但尽可能消除已有的和未来可能出现的耦合。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:32 | 显示全部楼层
本帖最后由 dasasdhba 于 2026-4-18 13:44 编辑

3. 函数式编程

函数式编程范式,是对程序设计的更进一步抽象,它将一切抽象为数据和函数,整体来看更像是做理论证明的工具。设 A B C 是一些数据类型,A→B,B→C 是一些函数,可以证明:面向对象设计当中所谓的对象,本质上就是一些复杂的函数组合。举例来说,设对象 A 有成员 B,本质上可以将 A 理解为一个无参的 () → B 常数函数——获取成员 B 的值,就是直接调用这个无参函数;更新 B 的值为 b,就是将 A 更新为 () → b 这么一个新的无参函数。如果 A 有更多成员,做法类似,留给读者思考。

这种强迫症一般地,将对象解释为一些函数的做法,显然只在理论上更有意义。不过,随着时代的发展,人们也不断从这套纯函数的范式中受到一些启发,并确实也解决了一些问题。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:33 | 显示全部楼层
本帖最后由 dasasdhba 于 2026-4-18 13:44 编辑

3.1 纯函数便于测试

称一个函数是纯的,若给定相同的输入,输出总是相同的。面向对象设计当中,很多函数往往都是不纯的,这通常是因为这些函数会改变一些存储在对象中的内部值。我们通常将这种造成函数不纯的操作称为副作用。不纯的函数会带来测试上的麻烦。当你预定的行为依赖不纯的操作,存在副作用时,为了达到预期的效果,你总是需要这些操作能够完成,从而没法简单地进行测试。纯函数显然没有这个烦恼,你只要在给定输入之后,观察输出是否符合预期就好了。

因此,如果一个逻辑可以做成纯的,那么为什么不呢?你完全可以将不纯的操作集中在其他地方(从而又成为 glue code),这样你就能得到一些易于测试的纯函数了。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 13:33 | 显示全部楼层
本帖最后由 dasasdhba 于 2026-4-18 13:43 编辑

3.2 不可变的数据

副作用出现的本质原因, 总是因为一些数据在运行过程中改变了,也就是程序中存在变量,这似乎是不可避免的。不过,比起在运行过程中改变一个量的值,直接声明一个新的量不也是一样的吗?这样,如果整个程序根本就没有变量,副作用就可以被完全消除。反复声明新的量,一个显然的担忧是,要给每一个量起不同的名字,这似乎很麻烦。不过,函数式编程语言的设计很聪明,它通常直接允许你声明同名的量,这会直接覆盖掉之前的旧值,但它本质上是新数据,不会带来任何数据的改变。你可能会质疑,总是有一些量是要在运行时改变的,比如计算 1 到 100 求和,通常需要在一个 for 循环中,对一个变量进行累加。不过,我可以告诉你的是,任何循环都可以写成递归函数的形式,而这种形式就不需要变量的存在——你可以将迭代后的值重新喂给递归函数,就这么简单。

总之,纯粹的不可变数据可以视为函数式编程的一大特征,无论是通过反复声明新的量,还是用递归改写循环,将变量逻辑改写为不可变的函数式风格总是可行的。直觉上,这当然会带来一定的性能损耗,算是以性能为代价,换取了程序的健壮性;事实上,现代的函数式编程语言通常默认是不可变风格,但这些语言通常也会提供一定的变量操作,你完全可以在需要权衡性能的地方,临时改成可变的风格。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 14:04 | 显示全部楼层
3.3 函数式封装

很多语言都引进了所谓的高阶函数的概念,比如 Python 的 map / reduce,C# 的 LINQ 等。这些函数的特点是用函数作为参数,所以被起名叫做高阶函数。以 Python 的 map 为例,这个函数本质上可以视为 (T -> U) -> List<T> -> List<U> 的函数,即接受一个 T -> U 的函数和一个 List<T>,返回一个 List<U>;这个函数具体做的事情很容易理解,就是把 List<T> 中的每一个元素都用 T -> U 这个函数作用一遍,最后返回一个 List<U>。

当你允许以函数本身作为函数参数时,原本很多看上去不太可能封装的过程,就变得可能了。封装的意义在于提高代码复用程度,而函数式的高阶函数风格允许你封装更多可能性,这也是为什么很多非函数式编程语言也经常会提供少许的这类封装——因为好处很明显。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 14:17 | 显示全部楼层
本帖最后由 dasasdhba 于 2026-4-18 14:18 编辑

3.4 函数组合

值得一提的是,如果将函数简单地视为 A -> B -> C 这样数据类型之间的箭头,你总是可以通过左结合或者右结合的形式,将这些箭头缩短。例如,设 f: A -> B -> C 是一个给定变元 a b 返回 c 的函数,再给定一个 A 常量 a0,那么 f'(b) := f(a0, b) 就是 B -> C 函数,在函数式语言中,这种操作通常记作 f' = f a0;另一个方向,给定一个 B 常量 b0,那么 f''(a) := f(a, b0) 就是一个 A -> C 函数,在函数式语言中,这通常记作 f'' = b0 |> f。更一般地,你可以像数学上的函数复合一样,将一些函数进行组合,例如,设 f: A -> B,g: B -> C,那么你自然可以将这两个箭头接起来,得到一个新的函数 A -> C,在函数式编程语言里面,这通常记作 f >> g。

很多过程都可以用函数组合的方式清晰地写出来,例如,你需要通过若干种方式反复更新一类数据 A,那么这些方式就可以理解为 A -> A 函数。拿一个具体的例子来说,设 A 为马里奥对象,我们可以定义如下的函数:

MarioWalk : A -> A
MarioJump : A -> A
MarioHurt : A -> A
...

最后,你只需要用函数式的方式将它们组合起来:

mario
|> MarioWalk
|> MarioJump
|> MarioHurt
...

回忆一般的面向对象程序,我们通常会将上面这些函数实现为带有副作用的,然后依次调用

MarioWalk()
MarioJump()
MarioHurt()
...

函数式风格如何在消除副作用的同时,又保持同样清晰的代码结构?这就是答案。
Moonstruck Blossom
个人网站:das-blog.pages.dev

165

主题

1206

回帖

6

精华

版主

绿色的糖果

经验
8983
硬币
1313 枚

达耶显示器Pro永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2025年第十四届MW杯亚军PK!MF5 冠军PK!MF4 季军最佳效率奖请务必再光临秘密合战!欢乐演员请务必再光临秘密合战!对不起,小姐

发表于 昨天 14:33 | 显示全部楼层
要长脑子了
【勇闯恐怖鬼屋】Mario Forever THE 震撼发布!!!
点我下载

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 14:45 | 显示全部楼层
本帖最后由 dasasdhba 于 2026-4-18 17:27 编辑

3.5 联合类型与 Railway

对于熟悉枚举类型的读者,可以简单将联合类型理解为增强的枚举类型:你可以给每一个枚举类附带一个数据,例如:

enum Result:
Ok A
Error B

是封装了 Ok 和 Error 两个枚举值的联合类型,其中 Ok 可以带有数据类型 A,Error 可以带有数据类型 B。这样,给你一个 Result 类型,你总是要通过 if 或者 switch,或者所谓的模式匹配,去判断它首先到底是 Ok 还是 Error,然后再拿到具体的值。设计这样的类型有什么好处呢?试想,程序运行的过程中经常会产生一些错误 Error,比如出现了 1 / 0 等等;可能发生的错误是需要尽可能处理的,不然程序就闪退了。而一般的,发生错误之后,你通常需要中断一系列的逻辑。比如说, MarioWalk 执行的时候卡墙了,你通常会期望后面的 MarioJump 等一系列的逻辑暂时中断,等待卡墙逻辑执行完成。如何实现中断呢?一个最简单的方式就是改写函数签名,使得函数在正常执行之后返回 A,否则返回 Error:

MarioWalk : A -> Result<A,Error>
MarioJump : A -> Result<A,Error>
MarioHurt : A -> Result<A,Error>

然后,编写一个辅助函数 bind: (A -> Result<A,Error>) -> Result<A,Error> -> Result<A,Error>,看上去很晕?其实就是把一个 f: A -> Result<A,Error> 变成一个 f': Result<A,Error> -> Result<A,Error>,如何实现呢?只需要讨论参数 a: Result<A,Error> 到底是 Ok 还是 Error,如果是 Ok,那么直接将 a.Ok.Value 喂给 f;若不然,直接返回 a(即直接返回 Error)。这样一来,我们的函数链就可以改写为:

Ok mario
|> bind MarioWalk
|> bind MarioJump
|> bind MarioHurt

注:

1. 读者可自行验证,bind MarioWalk 的签名是 Result<A,Error> -> Result<A,Error>
2. 这里的 bind 函数,通常能在函数式编程语言的标准库里面找到,所以也不用自己写

这样,只要中间哪一步返回的 Result 类型是 Error,那么整个链条所做的事情就是一直将这个 Error 传递下去,这就实现了逻辑的中断,且同时保留了错误信息。这样的编程风格就是所谓的 Railway:为了在发生错误时中断逻辑,我们并没有引入任何的状态量,而是将整个链条分为 Ok 和 Error 两个 Railway,如果是 Ok,则正常运行;如果是 Error,这里我们的选择就是直接返回 Error。这种编程风格当然还有更多更广泛的应用,这里只是举个例子;一般地,你可以根据自己的需要,设计多个 Railway,然后通过类似的手法将它们链接起来,这就在既保持结构清晰的同时,又避免了过多的状态量引入。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 15:13 | 显示全部楼层
本帖最后由 dasasdhba 于 2026-4-18 15:40 编辑

3.6 计算表达式(Monad)

每当你拿到一个 Result 类型的时候,你都要判断它到底是 Ok 还是 Error,感觉很麻烦吧?没关系,通过所谓的计算表达式,或者又叫做所谓的 Monad,这个工作可以得到完美的简化。现在,假设 result 是一个 Result<A,Error> 类型的值,doSomeCalcWith 是一个 A -> B 函数,考虑如下代码:

  1. monad {
  2.         let! a = result
  3.         doSomeCalcWith a
  4. }
复制代码


这里,let! 操作符的含义是,如果 result 是 Ok,则赋予 a 以 A 类型的值;否则,整个 monad 片段直接返回 Error 值。最后,将 B 包装为 Result<B,Error> 作为结果。这是如何做到的呢?本质上跟上一节提到的 bind 函数在原理上是类似的。

一般地,要想定义一个 Monad 片段,至少需要定义两类函数:

1. Bind: Result<A,Error> -> (A -> Result<B,Error>) -> Result<B, Error>
2. Return: B -> Result<B, Error>

当你写出上面那段代码时,编译器会将其等价转换为:

  1. Bind(
  2.         result, func a -> {
  3.                 Return(doSomeCalcWith a)
  4.         }
  5. )
复制代码


因此,要想达到我们预期的效果,只需要定义:

  1. Bind(x, f) := if x.isError then x else f(x.Ok.Value)
  2. Return(b) := Ok b
复制代码


注:若 B = A,则这里的 Bind 函数跟上一节完全一致。

总之,Monad 就是一些预定义的函数操作,编译器可以将 monad 块自动替换成正确的函数组合,这就把每次都需要做的,判断 Result 到底是 Ok  还是 Error 的工作,自然又便捷地封装起来了;当然,除了 Bind 和 Return 之外,还可以定义更多的操作,这里我们只是举了一个简单的例子。事实上,自己编写 Monad 块可能确实会有些不好理解,但用起来还是相当直观的,而且你总是可以在开源项目中找到大家已经写好的很多 Monad 块,因而真正需要自己做的工作其实也不多。
Moonstruck Blossom
个人网站:das-blog.pages.dev

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 昨天 15:16 | 显示全部楼层
暂时想不到更多的了,本帖暂时完结,如果想在本帖讨论更多范式和架构相关的问题,或者有什么疑问,欢迎在本帖讨论。
Moonstruck Blossom
个人网站:das-blog.pages.dev

251

主题

1488

回帖

7

精华

活跃锤龟

只是普通的音mader而已

经验
9706
硬币
1437 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2021年第十届MW杯冠军2024年第十三届MW杯亚军PK!MF2 冠军PK!MF6 亚军综合发挥奖最佳人气奖人气之王人气之王欢迎光临秘密合战!他山之石

发表于 昨天 15:32 | 显示全部楼层
哇哇

遵循一切喝了之力的指引!!!

1

主题

3

回帖

0

精华

小红刺猬

经验
771
硬币
250 枚

永吧元老

发表于 昨天 20:28 | 显示全部楼层
都说到Monad了不说一下CPS吗?这东西作为async/await的基础,我感觉用得比Monad还广泛。

47

主题

940

回帖

19

精华

版主

经验
10447
硬币
2012 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章2016年第五届MW杯亚军对不起,小姐盲猜大王请务必再光临秘密合战!数字君X68数字君X68数字君X78

 楼主| 发表于 6 小时前 来自手机 | 显示全部楼层
Sirius 发表于 2026-4-18 20:28
都说到Monad了不说一下CPS吗?这东西作为async/await的基础,我感觉用得比Monad还广泛。 ...

await / async 这套东西确实用得非常广泛,不过 CPS 这个概念我还是第一次听说;我简单搜了一下,这个概念对照 async 的原理来看还是蛮好懂的,但是怎么说呢,函数式风格可以比较轻松地用 monad 封装这一点,但是像 C# 这些语言,给我的感觉就更像是将 async 这套东西硬性地规定到了语言的特性本身,显得比较不伦不类的。这东西到底是函数式风格还是什么风格,我感觉我也说不清了(
Moonstruck Blossom
个人网站:das-blog.pages.dev
您需要登录后才可以回帖 登录 | 创建账户

本版积分规则