我的学习笔记

土猛的员外

AI大模型面面观,还不上车?

这可能是有史以来最大的变革

AI会把人类的工作抢完?

这波AI巨浪是由OpenAI掀起的,ChatGPT就是那个炸起巨浪的炸弹。通过ChatGPT这类对话式应用的使用,我相信大部分朋友都已经知道了这波AI和以前很不一样,这次不开玩笑!

2030

看看红杉资本去年发的一篇AI将逐步取代人类工作的预测,以目前全球AI军备竞赛的状况来看,极有可能会到来的更快。到2030年,以下工作会被取代:

  • 文字工作:影响到绝大部分的办公室白领,但是别担心,只要老板还是个人,他会留几个人聊聊天的;
  • 编程和计算:影响到程序员、架构师、数据库工程师等等;
  • 美术和设计:UI设计师、建筑设计师、非顶级艺术家、摄影师等等;
  • 视频、3D制作与游戏开发:电影工作者、3D制作工程师,以及游戏设计和开发人员。

那么问题就来了:我们的孩子们怎么办?

他们现在学的,大差不差还是我们以前学的内容,但是他们面临的工作可不是我们出社会时面对的工作了,虽然长期来看AI可以让人类更少工作、更多获得。两次工业革命、一次信息化革命,至少没有让现有知识体系产生动摇,相反让知识体系变得更加重要,学历就变得尤为重要。但这次(算第四次)革命却一锤一锤地在敲碎人类几千年的知识体系。

这个问题也困扰了我好些时间,最终,我觉得自己就做个AI降临派吧——去融入,去拥抱,去理解LLMs(大语言模型)、在工作中使用AI去提升工作,比如用好Prompt。我觉得自己学习都还不晚,那孩子们就更不用担心了,他们会深度使用好AI,就像我们熟练使用计算机和汽车一样。

不做砸珍妮纺纱机的人

好吧,那就先保持自己不落后。

飞船

我觉得目前的AI就像一个从未见过的新机器,从人类以前没有想象过的方式和速度呼啸而来。我们不能等到一年后才后知后觉,那时候已经追不上了…

我们需要现在就开始,从了解底层的技术——大模型开始,再来看看我们需要怎么去拥抱AI。

各类大模型概览

首先我们来看大模型。AILLMs

这是我自己整理的目前大模型分类,当然了肯定不止这些,但是这些对我来说相对熟悉,且本身也具备一定代表性。

行业标准OpenAI

这次冲在最前面的大模型是基于GPT(Generative Pre-trained Transformer,基于Transformer的生成式预训练模型)的GPT-3.5、GPT-4。这些模型的基础信息我就不多说了,大家可以参考之前的文章(#链接)。

OpenAI的产品当然不止ChatGPT,他们还有作图的DALL·E、语音转文字的Whisper,以及自动代码工具等。

OpenAI在GPT-2的时候是开源的,但是到GPT-3之后就闭源了,目前外界不知道OpenAI目前的最高能力已经到什么层度了,他们是这一波潮流的引领者。

搅局者LLaMa

目前在OpenAI的开源对手里面,最活跃的是Meta(原Facebook)出品的LLaMa大模型,他们拥有7B(70亿参数,后同)、13B、33B和65B等多个版本,能力也是依次上升,但是硬件要求也会越来越高。作为拥有深度学习三巨头之一杨立昆的Meta,本来应该是想在AI大模型界当一个Android的,那个最大的“在野党”,就像现在Android之于iOS。但是LLaMa目前申请权重文件非常麻烦(虽然其实已经泄露),加上不能直接用于商业,所以在商业公司层面上已经有一丝丝降温。

基于LLaMa的升级很多,先是有斯坦福大学主导的升级版——Alpaca,再是一直在宣传多么厉害多么厉害、几个大学联合制作的Vicuna。因为LLaMa的商用问题,所以我就没有去下载部署,只是在线试用了LLaMa的集大成者Vicuna。就中文来说,Vicuna可不咋滴,当然了,如果是本地部署,不断训练,肯定是会明显好于现在的能力。

另外,LLaMa大模型还有比较多的中文版或LoRA版(可以理解为简配版,为了大家更容易在消费级显卡上炼丹),比如用C++重新做了一遍的LLaMa.cpp,听说效率提升了不少。还有LLaMa的中文版,专门喂了很多中文预料来训练的,就是Chinese-LLaMa-Alpaca,还未亲测。Vicuna也有中文版,大家有兴趣可以下载安装试试,反正我对LLaMa一类的,还是继续观望,等它可以商用再说吧。

老牌劲旅

这次AI大爆发的一个核心技术组件是Transformer,它很好地解决了机器的长期记忆的问题,这个技术的发明者就是Google。但是Google却在后来移情别恋,以至于OpenAI用了大量Google挖过来的人,发布了ChatGPT之后才发觉事情不妙,仓促发布了Bard。虽然我觉得就技术和数据来说,Google都是有希望追上来的,但是他们的现有业务会不会限制他们在AI上发力(毕竟这和搜索肯定是有直接冲突的),这是一个创新者的窘境。

另外一家目前已经很不错的老牌劲旅是Claude,而且现在Claude提供免费API与Slack结合,对OpenAI已经有一些冲击了。

对于亚马逊来说,他们的Titan肯定还是基于云,买买买,我都能服务。

中国选手

下面出场的是中国选手,网络上已经有一张图了,展示了十多家国内的参赛选手。但是仔细来看,目前可申请试用的应该自有百度的文心一言和阿里的通义千问

百度有丰富的搜索数据,加上有陆奇、吴恩达等多位货真价实的国际顶级大牛,还是有一些积累的,应该是村里面第一个站出来的人。只是前面可能更多的方向是在无人驾驶(更多是在感知智能),而忘了点认知智能的科技树。

阿里这次动作有点迅速,这一块感觉并不是阿里强项,虽然同样拥有足够的数据,拥有TensorFlow和Pytorch1.0的作者之一贾扬清(已离开阿里)。

目前外界最看好的第三家可以站出来是字节跳动,有钱、有数据、有用户,还有人才。

至于其他的公司,感觉都还是比较难的,这件事情,再去找开源模型包个壳其实意义不大,因为这里还要涉及到数据、算力和训练,以及工程化地调优。数据把一大众公司挡在门外了,然后算力又把另一家巨头挡住。最后,很多公司是真的没有技术、没有人才密度的,拿项目也许没问题,但是这种拼硬实力的事情,怎么可能做得成。

这次清华大学无愧国内NO.1的存在,推出的ChatGLM还不错,特别是对于中文的支持。我自己部署了ChatGLM-6B的模型,在我的RTX-4090上跑起来还是很顺畅的,但是也只能先玩着了,因为听到他们7位数的授权费,感觉为了后面不吃官司,还是要提早找一个可商用的大模型练起来。

6b

第一个可商用的开源大模型

这周,第一个可商用大模型它就来了,是Databricks发布的Dolly2.0。首先介绍一下Databricks,它最著名的产品就是Spark,对的,就是那个Apacha Spark。

spark2

说回Dolly2,为什么它可以商用的?先从LLaMa系列大模型为什么不能商用说起。

大模型的技术开发原理其实对于大部分顶尖公司来说不难,因为前面说了GPT-2是开源的,而且牛逼的公司可以根据Google、OpenAI等发布的论文中描述的技术原理开发出大模型。但是,训练却很难,最大的问题就是训练数据,据说OpenAI里面30%的人是在做数据标准、校验等工作,而且在训练ChatGPT的时候,ChatGPT还采用众包方式,让大量肯尼亚人参与数据处理,花费了不少钱。而LLaMa、Alpaca和Vicuna等都使用了ChatGPT去获得非常有价值的原始数据(问题与配对的答案),等于转载使用,所以存在商业化问题。

而Dolly呢?他们在Dolly1的时候也是使用ChatGPT来获得训练数据的,所以依然无法商业。于是,Databricks做了一个大胆的尝试,让公司的5000员工去想,不能借助ChatGPT,也不能直接去其他网站是抄版权内容,全部要自己想,还要保证质量,为此他们还办了个内部比赛。就这样,Databricks在一周内完成1.5万条数据,平均每人3条,因此,他们的这个模型是可以商业的。这个故事在他们的官方博客上有介绍,可以查看。

关于Dolly2,代码可以去Github:databrickslabs/dolly上查看;可商用的模型在Hugging Face:dolly-v2-12b(23G的基于Pytorch的Model)。

目前看到的个别已经部署过的推友评价,中文的处理能力是够呛的,但是咱们不怕啊,数据就是咱们优势啊(后面会讲到是为什么)。

作图模型

Midjouney目前的知名度应该仅次于ChatGPT,作图最好的平台之一,但是真的要用,最好是付费(我已经交了20美元)。

对了,首图就是Midjouney上制作的,还包括下面这张图(一艘GPU飞船):

GPU飞船

目前最大对手是开源的Stable Diffusion,可以直接部署在像我这样的消费级显卡上的,而且处理速度极快。当然,在普遍的自动生成图片质量上还是不如Midjouney的。作图这个服务,对于prompt的收集、意见反馈等感觉比ChatGPT还重要,更加需要人类反馈自学习(RLHF)。

AI管家出现

前面说了这么多大模型,都还没说太多基于这些大模型的应用呢,嗯,我也不太想展开来讲应用,太太太多了。

那么面对这么多的AI应用,怎么简便地去使用呢,怎么样能让我写出更好的Prompt呢?

这时候,LongChain就出来了,一种可以自动帮助我们把一个个应用串起来的应用。

然后,微软出了一个Jarvis(贾维斯),听名字你应该知道是什么了;

这几天最火的,当然是Auto-GPT了,一个月不到,github上的stars已经过了6万。

auto

我们有弯道超车的办法

看到上面说了这么多大模型,会不会觉得头麻了。如果你是第一次接触,那就是麻妈给麻子开门,麻到家了!

但是这里面中国企业起步晚,参与少,对于AI这种必然的未来趋势,我们该怎么办呢?有办法像移动互联网一样后发先至吗?

我觉得是有的,这里非常简单的聊一下。

AI这个事情,主要是有三个决定因素:大模型、算力和数据。

大模型今天已经说了,其实开源的肯定也会不断完善,加上相关核心论文是公开的,这方面对我国来说应该不是最大的问题(君不见,国外这一波,华人比例非常之高);

算力应该也不会是长期的问题,芯片这个问题,我就不多谈了,反正这个不会成为长期的问题;

那么剩下的就是数据了。前面也说了,数据非常重要,也是模型“聪明”还是“白痴”最重要的影响因素。国内的弯道超车应该就在这里了。大家应该还有印象,今年的Q1,国家强调了数字化改革,然后由宣布组建国家数据局。我不知道大家怎么看,虽然也看到一些冷嘲热讽,但是我觉得国家数据局最终的目的是要把国内大部分的重要领域的数据都要收集上来。然后(我yy一下),进行大模型的训练。如果有充分详实的数据,以后很多事情就好办了。比如问“怎么让杭州市民用更低的价格,或者更丰富更便宜的菜品?” 如果是数据充分的AI大模型,会给出好几条方案,其中可能有(依然是yy,内容上别当真)“增加安徽、山东到杭州的高速公路建设,增加蔬菜、肉类等食材的供给,中间涉及到XX个县,根据当前土地补贴政策,整个工程造价XXXX”。

然后呢,我觉得可能只有中国在数据量(看看移动互联网我们是怎么用的,各地支付、遍地小哥)、数据汇总组织统筹能力上都是最佳的,所以在数据这个事情上,我们是可以后发先至,弯道超车的。

东市买骏马,西市买鞍鞯

对于个人呢,前面说了,要先扒上这趟车,不能再等等,等等就可能追不上了。

对于有编程基础的朋友,可以恢复Python的学习、Pytorch的学习等,把大模型研究起来,开发个基于大模型的应用玩玩;

对于产品经理,每天你需要花一个小时去看看这个世界在AI领域发生了什么,借助AI,甚至可以将所有应用重做一遍;

对于所有人,如果我今天说的大部分内容你都没有听过,那你已经接近被时代甩远的危险了,找资料好好学。比如书、比如博客,也有类似极客时间这类,大家也可以看看,每天上下班都可以听,真的别落后了。

ad1ad2

上周末,花了22400元攒了台主机,主要是为了那块RTX4090。有句话是“要做瓷器活,先要有金刚钻”,所以这台电脑,要买!

rtx4090

AI是一个新时代的战场,个人玩家也可以玩,但是,先准备好你的装备吧。木兰诗:“东市买骏马,西市买鞍鞯”,买好上战场了!

这是微信公众号的文章链接:土猛的员外






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



ChatGPT的原理、咒语和应用

前言

今天给大家讲讲ChatGPT的工作原理,如何使用,基于它的应用等内容。

ChatGPT的工作原理

其实要讲清楚ChatGPT的原理是很难的,因为我看的几篇能把原理讲清楚的文章基本上都在3万字以上,而且还配了大把的公式和图片。但是我不想这么写,所以就抛弃掉很多细节(特别是数学公式和数学图例),希望能用大白话给大家讲清楚,let’s go!

ChatGPT最浅显的一个工作原理就是:一个单词一个单词的输出,它会根据前面的内容猜测后面应该输出什么单词——是的,这就是ChatGPT最浅显也是最核心的原理,如果你不想深究技术方面的内容,那记住这一点是非常重要的。当ChatGPT在写一篇文章时,它实际上只是一遍又一遍地问自己:“在已有的文本基础上,下一个词应该是什么?“

jinwan

为了能够输出下一个词,你可以想象的到,ChatGPT的内部是有一个概率表的,比如上图所示的”今晚我想和你一起“和后面的词之间的概率表,然后它每次都带有一点随机的挑选概率最高的20%的词。嗯,那么接下来你可能就会问:

概率哪里来的?

首先说答案:ChatGPT”吃“下去很多文本内容,从这些文本中将发现了各个词之间的关系,做了这个统计表。

为了你可以更容易理解,我再举个例子:我们把维基百科搜索”猫“,把前100篇英文文章进行字母统计,得到a后面各单词的概率、b后面各单词的概率……统计后你可以看到如下这样一个字母对的概率表(”2-gram“):

img

根据这个表,我们可以看到,r、s、t和n几个字母后面,a出现的概率都极高,回想”今晚我想和你一起“之后的概率,我们应该可以知道概率是怎么来的了。

但是!!!字母的统计是没有用的,我们至少要把最小颗粒度变成单词,单词一个个联想才有意义。英文中目前有4万个单词,如果像上面的字母一样进行单词的二元组合的话,可以得到16亿个结果,如果是三元就是60亿个结果….如果是20个单词的文章片段呢?可能数量已经超过了宇宙中所有的粒子数量。那怎么办呢?

人类的归纳能力:模型

我们不需要去穷举每种可能性的,因为人类很善于归纳总结,我们早就已经会用模型了。

如果你不清楚什么是模型的话,我们先来看一个非常简单的模型:比如说我们要测试物体落地的距离和时间。我们可以在1楼、2楼、3楼、….、1万楼分别扔下来一个高尔夫球,测试落地时间。当然,真的这么去穷举实验的话你就傻了,我们只需要取其中的一些楼层做实验就可以了。如下图(X轴:层数,Y轴:落地时间):

img

根据这些实验,我们就可以用数学来做模型了,比如:
$$
y=a+bx+cx^2
$$

img

当然,要调整到这个样子,需要不断去调整模型,模型可大可小,有最简单的y=ax(一个参数),有上图这样的(三个参数的)一元多次函数,还有由各种简单模型不断叠加的大模型,参数就更加复杂了。比如ChatGPT-3.5的模型参数高达1750亿个,不仅仅是数值函数,还包括图像识别函数模型、自然语言处理(NLP)的模型等等。

mimapoyi

那么这1750亿的参数是怎么调整出来的?那肯定是用计算机自动调整的,不可能是人去拧这些螺丝。这里的螺丝指的就是参数,计算机出现之前,图灵做的密码破译机(计算机的原型)的参数就是用螺丝来拧的。这里面就需要引入机器学习和神经网络了。

AI的仿生:神经网络

目前最流行和最成功的方法使用神经网络。神经网络在1940年代被发明出来,其形式与今天使用的形式非常接近,可以被认为是大脑运作方式简化后的理想化模型。人类大脑中有约1000亿个神经元(神经细胞),每个神经元能够产生电脉冲高达一千次/秒。这些神经元以复杂网状连接在一起,每个神经元都有树枝状分支,使其能够向数千个其他神经元传递电信号。粗略地说,在任何给定时刻一个特定的神经元是否会产生电脉冲取决于它从其他不同连接收到了哪些“权重”不同的信号。

在计算机中,人们就发明了人工的神经元——感知机。感知机得作用就是接收三个输入条件x1、x2、x3,输出一个结果。

image-20230402110056251

这么说可能有点难理解,我们讲的例子。我们可以将感知机当做一个根据权重来做决策的机器,以周末是否去外面野餐为例,我们有可能有三个影响因素:

  1. 天气情况?
  2. 你的家人是否想去?
  3. 附近是否可以方便地停车?

$$
这三个因素可以称为x_1、x_2、x_3。如果天气好,那么x_1=1,否则x_1=0;类似的,x_2=1或x_2=0。
$$

但各个因素的重要性不同,如果下雨,那野餐的几率为零。
$$
所以我们把x_1的权重设置的更大,如w_1=6,其他权重w_2=2,w_3=2。
$$
如果我们给这个感知机的阈值设置为5(最终的结果>5就出行),那么只要天气情况好,那最终输出就是1(去野餐),其他两个条件几乎成了摆设。这里,三个x的权重和最终的阈值就是所谓的参数!!!

sigmoid1

通过这么一个多因子的权重分配系统,就把一个决定给做出来,是不是有点神奇。其实神经网络实际比这个复杂的多,因为这里仅仅是一层,实用的神经网络都具备很多层,如下图所示:

shenjingwangluo1

我们刚才做出来的野餐出行决定仅仅是其中一个人工神经元,将这个结果传递给下一层的神经元(上一层的输出结果作为下一层的输入因子)进行更复杂的判定。需要注意的是一个神经元的输出只有一个,上图其实一个神经元有多个箭头输出只是说明同一个输出可以被下一层的多个神经元接收作为输入因素。

当然,说回感知机,有些感知机是“F二代、G二代”,可以赢在起跑线上,比如我们加一个偏置值4,那么不管三个条件哪一个满足,最终的值都大于5了,都可以去野餐,这就是二代们的霸气…..这个偏置值b,以及前面的w,是不是就是我们最上面那个一元二次函数的a(偏置值)、b、c啊。

如果你认真看到这里,那么就可以知道数学函数与神经元之间的关系了。现在我们要做的事情是要让机器能自动帮我们找到权重和偏置值(w、b),使得神经网络输出的y(x)能够拟合(匹配)各种情况。这里就需要展现著名的损失函数了,有些地方也叫代价函数。
$$
C(w,b) \equiv \frac{1}{2n} \sum_x |y(x)-a|^2
$$
这里就不展开函数的具体细节了,目前常见的训练方案是使用梯度下降算法来找参数,以便最小化C(w,b)。梯度下降是一种微积分里面的求导函数,用图表来表示的话,它们在做的事情就是不管输入点(比喻成一个小球)在哪个位置,让它滚动去找最低点。整体的正地点找到了的话,那么这时候的参数就是我们要找的参数了。

img

要说明白ChatGPT的原理其实还有很多大的概念,但其实它就是一个巨大的神经网络——之前使用的是1750亿个权重版本的GPT3.5网络。在很多方面上,这个神经网络的特别之处在于处理语言的能力,并且它最显著的特点是它基于“Transformer”构建了神经网络。Transformer 的想法是对组成文本片段序列标记进行类似操作, 但不同之处在于 Transformer 引入了“注意力”概念和更加关注某些序列片段而非全部. 或许总有一天只需启动通用神经网络并通过训练进行所有自定义就足够了,但至少目前来看,在实践中“模块化”事物似乎是至关重要的——正如 Transformer 所做的那样,也可能与我们的大脑所做的类似。

ChatGPT的总体目标是根据其训练过程中看到的内容(即查看了来自Web、电子书、word等数十亿页文本),以“合理”的方式继续文本。因此,在任何给定时刻,它都有一定数量的文本,并且其目标是为下一个要添加的标记提供适当选择。

它分为三个基本阶段:首先,它获取与迄今为止已有文本相对应的标记序列,并找到表示这些标记序列的嵌入式数组(即一组数字)。然后,在神经网络中进行操作——通过在网络中连续层之间传播值——以生成新嵌入式数组(即新一组数字)。接着,它取该数组最后部分并从中生成大约50,000个值得出不同可能下一个标记概率的数组。

好了,你能感觉到越到后面我越讲不清楚了,那是因为后面需要太多的公式和图例,我直接放上来的话,懂的人自然懂,但是大部分想了解ChatGPT的朋友可能就很难理解了。不过,ChatGPT的基本概念在某种程度上相当简单:从网络、书籍等大量人类创作文本样本开始,然后训练神经网络生成“类似于这些”的文本。

和BERT的比较

这里需要补充一点就是ChatGPT中的GPT和BERT的区别。这两者都是基于Transformer的,但是BERT相对来说考虑的比较周全一些,它会去分析上下文(双向的)和做推理,我们可以认为是它对我们问的话做了一些思考,再返回。这里的返回形式,包括去召回既有的知识库,还有就是用GPT来生成。

但是GPT的模式就粗暴的多,它可不考虑这么多,直接就是根据已有的文字(比如我们输入的问题或者一些全局性的文本内容)一个单词一个单词地往下走。GPT更像是东方朔测字算命,我不想了解你,你就写几个字,我根据自己读过的“一千个北大图书馆”的知识,看字来算….

AI魔法师的咒语——Prompt

正是ChatGPT这种不求甚解,只根据我们键入的文本进行推演的工作方式,造成了我们怎么“问”就非常重要了!

这个问法,就是Prompt,也叫提示,这是现代AI魔法师的咒语啊!

我们先来看看几个简单的问法。

01.翻译

翻译1

翻译是最常见的用法之一,目前也有一些翻译插件,挺好用的,后面也会提到。

02.归纳总结

归纳

不仅仅是归纳,我还可以让它单独罗列一些内容:

归类列表

我觉得ChatGPT能力还是很强的,就这两下子,已经可以把一些“理工男”打败了。

03.润色

润色-1

再让它帮我们把文字润色一下,可以有很多种风格,包括鲁迅、李白的等等,只要你能想到,如果你写过大量文章,也可以喂给ChatGPT让它学习你的语言风格,以后可以让它用你的说话风格把一些文章给润色一下。

04.日程安排

日程安排-约会议时间

你可以把自己和朋友的日程告诉它,让它来帮助预约会议,我这里展示的是两个人,更多人的依然有效。

这只是我之前能想到的内容….然而,真正的ChatGPT魔术师的Prompt可就了不得了,我分享一下别人的内容。

image-20230402190246103

是的,这就是专业啊!原文地址

网上其实还有很多的prompt的网站,大家有兴趣可以自行搜索。

另外,OpenAI的创始人Sam Altman的想法是5年之内解决prompt的问题,以后的交互会更加简单。言外之意prompt也许是你接下来最需要学好的知识。

这五年,“用好prompt你”可以把“不使用promt的你”甩开十八条街,你信不信!!!

基于ChatGPT的应用

基于ChatGPT的应用现在太多了,除了网页版的ChatGPT之外,我自己最先用的、也算用的比较多的是翻译。

01.翻译工具

我用的是yetone开发的产品,大家可以在Chrome应用商店搜索“OpenAI Translator”找到这个插件,它除了多种语言的翻译,还可以做归纳、润色等工作。

fanyi_yetone

02.个人知识库

看到倪爽老师在用Jiayuan开发的Copilot Hub创建自己的知识库,他把自己的所有推文(大家可以理解为微博上自己发过的所有文字)都导出来,放进这个知识库中。然后,随时可以对“自己”提问,问问以前对某件事的看法,问问就自己的价值观来说,当前面临的这件事情以前的“自己”会怎么选择。是不是有点《流浪地球2》中数字意识的感觉了。

ns1 ns

Copilot Hub的地址是Copilot Hub

03.OpenAI插件

chajian

随着OpenAI开始发布插件,官方的说法是有了眼镜和耳朵,让内容更加实时了,基于OpenAI的应用肯定会出现井喷。有些应用,我相信OpenAI他们自己也没想到。未来会怎么样,值得期待!

这一波AI潮的其他优秀应用

这一波AI浪潮因ChatGPT的横空出世而被推到了大众面前,其实大部分的应用前几年就已经在发展了。虽然多次听到OpenAI与医疗、量化等行业的公司有密切合作,但下图中的大部分应用其实和OpenAI没什么关系。

img

前往查看详细AI应用列表

微软、Adobe等都已经在大步迈进AI,我们现在几乎每天都能听到很多新的基于AI的产品。嗯,当然,这也激起了一些人的紧张,比如马斯克等人联名要求暂停比GPT-4更先进的AI研发6个月。

好,不去管这些科学伦理的事情,我想介绍一位我认识的创业者,他可算是把AI用的非常溜了。

yong

以前要做数字人,制作3D是一件非常重的事情,而且价格昂贵。但是这次,他直接在家里只用一台电脑和网线,通过各种prompt就把一段数字人介绍他自己公司产品(Omniedge,一种可以连接任何设备到局域网的方案)的视频做好了,用的是他在midjourney上生成的理想代言人,还有普通话、粤语、陕西话等多个版本。数字人行业应该会被迭代,特别是制作3D模型的人,以后,真的不一定还值钱。

总结

去拥抱未来吧,何况未来已来!






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



Rust原子性和锁(01)-Rust并发基础

文本为《Rust Atomics and Locks》翻译文章,英文原文:https://marabos.nl/atomics/basics.html
仅供学习使用

早在多核处理器普及之前,操作系统就允许一台计算机同时运行多个程序。这是通过在进程之间快速切换来实现的,允许每个进程一个接一个地重复地取得一点点进展。如今,几乎我们所有的电脑,甚至我们的手机和手表都有多核处理器,这可以真正地并行执行多个进程。

操作系统尽可能地将进程彼此隔离,允许程序在完全不知道其他进程在做什么的情况下做自己的事情。例如,如果不事先询问操作系统内核,一个进程通常不能访问另一个进程的内存,或以任何方式与其通信。

然而,一个程序可以产生额外的执行线程,作为同一个进程的一部分。同一进程内的线程并不是相互隔离的。线程共享内存,并可以通过该内存相互交互。

本章将解释Rust中线程是如何生成的,以及围绕线程的所有基本概念,比如如何在多个线程之间安全共享数据。本章解释的概念是本书其余部分的基础。

如果你已经熟悉Rust的这些部分,可以跳过前面的内容。然而,在你继续下一章之前,确保你对线程、内部可变性、“发送”和“同步”有很好的理解,并且知道互斥量、条件变量和线程停放是什么。

Rust中的线程概念

每个程序都从一个线程开始:主线程。这个线程将执行你的“main”函数,如果需要的话,可以用来生成更多的线程。

在Rust中,使用标准库中的’ std::thread::spawn ‘函数来生成新线程。它只有一个参数:新线程将要执行的函数。一旦这个函数返回,线程就会停止。

让我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::thread;

fn main() {
thread::spawn(f);
thread::spawn(f);

println!("Hello from the main thread.");
}

fn f() {
println!("Hello from another thread!");

let id = thread::current().id();
println!("This is my thread id: {id:?}");
}

我们生成两个线程,它们都将执行’ f ‘作为它们的主函数。这两个线程都将打印一条消息并显示它们的thread id,而主线程也将打印它自己的消息。

Thread ID

Rust标准库为每个线程分配一个唯一的标识符。这个标识符可以通过’ Thread::id() ‘访问,类型为’ ThreadId ‘。没有太多你可以做一个’ ThreadId ‘除了复制它周围,并检查是否相等。不能保证这些id是连续分配的,只能保证每个线程的id是不同的。

如果您多次运行上面的示例程序,您可能会注意到每次运行的输出是不同的。这是我在机器上运行时得到的输出:

1
2
3
Hello from the main thread.
Hello from another thread!
This is my thread id:

令人惊讶的是,部分输出似乎缺失了。

这里发生的情况是,主线程在新生成的线程完成执行它们的函数之前完成了’ main ‘函数的执行。

从’ main ‘返回将退出整个程序,即使其他线程仍在运行。

在这个特殊的例子中,一个新生成的线程只有足够的时间来处理第二条消息的一半,然后程序就被主线程关闭了。

如果我们想要确保线程在我们从’ main ‘返回之前完成,我们可以通过“加入”它们来等待它们。为此,我们必须使用’ spawn ‘函数返回的’ JoinHandle ‘:

1
2
3
4
5
6
7
8
9
fn main() {
let t1 = thread::spawn(f);
let t2 = thread::spawn(f);

println!("Hello from the main thread.");

t1.join().unwrap();
t2.join().unwrap();
}

‘ .join() ‘方法会一直等待,直到线程完成执行,并返回一个’ std::thread::Result ‘。如果线程因为恐慌而未能成功完成其功能,则该文件将包含恐慌消息。我们可以尝试处理这种情况,或者在加入一个恐慌线程时调用’ .unwrap() ‘来引发恐慌。

运行这个版本的程序将不再导致截断的输出:

1
2
3
4
5
Hello from the main thread.
Hello from another thread!
This is my thread id: ThreadId(3)
Hello from another thread!
This is my thread id: ThreadId(2)

唯一在运行之间仍然会改变的是消息打印的顺序:

1
2
3
4
5
Hello from the main thread.
Hello from another thread!
Hello from another thread!
This is my thread id: ThreadId(2)
This is my thread id: ThreadId(3)

Output Locking(输出锁定)

println宏使用’ std::io::Stdout::lock() ‘来确保它的输出不会被中断。println!() 表达式将等待任何并发运行的表达式完成后再写入任何输出。如果不是这样的话,我们可以得到更多的交错输出,比如:

1
2
3
4
5
Hello fromHello from another thread!
another This is my threthreadHello fromthread id: ThreadId!
( the main thread.
2)This is my thread
id: ThreadId(3)

与其像上面的例子一样将函数名传递给 std::thread::spawn,更常见的做法是传递一个 闭包。这允许我们捕获值并将其移动到新线程中:

1
2
3
4
5
6
7
let numbers = vec![1, 2, 3];

thread::spawn(move || {
for n in &numbers {
println!("{n}");
}
}).join().unwrap();

在这里,由于我们使用了 move 闭包,因此将 numbers 的所有权转移到了新生成的线程。如果我们没有使用 move 关键字,则该闭包会通过引用捕获 numbers。这将导致编译器错误,因为新线程可能会超出该变量的生命周期。 由于线程可能一直运行到程序执行结束,所以 spawn 函数在其参数类型上有一个 static 生命周期限制。换句话说,它只接受可以永久保留的函数。通过引用捕获本地变量的闭包可能无法永久保留,因为该引用将在本地变量停止存在时失效。 从线程中获取返回值是通过从闭包中返回它来完成的。可以从 join 方法返回的结果中获取此返回值:

1
2
3
4
5
6
7
8
9
10
11
let numbers = Vec::from_iter(0..=1000);

let t = thread::spawn(move || {
let len = numbers.len();
let sum = numbers.iter().sum::<usize>();
sum / len 1
});

let average = t.join().unwrap(); 2

println!("average: {average}");

这里,线程闭包返回的值(1)通过 join 方法(2)发送回主线程。

如果 numbers 为空,线程在尝试除以零时会出现 panic(1),join 将返回该 panic 消息,导致主线程也因为 unwrap 而 panic(2)。

Thread Builder

std::thread::spawn 函数实际上只是 std::thread::Builder::new().spawn().unwrap() 的简便写法。 std::thread::Builder 允许您在生成新线程之前设置一些设置。 您可以使用它来配置新线程的堆栈大小并为其命名。 线程名称可通过 std :: thread :: current() .name() 获得,在恐慌消息中将被使用,并且在大多数平台上的监视和调试工具中可见。 此外,Builderspawn 函数返回一个 std :: io :: Result, 允许您处理生成新线程失败的情况。 如果操作系统耗尽内存或者资源限制已应用于程序,则可能会发生这种情况。 如果无法生成新线程,则 std :: thread :: spawn 函数仅会引发 panic。

作用域Threads

如果我们确定一个生成的线程肯定不会超出某个范围,那么该线程可以安全地借用一些不会永久存在的东西,比如局部变量,只要它们在该范围内存活。 Rust标准库提供了std::thread::scope函数来生成这样的作用域线程。它允许我们生成不能超出传递给该函数的闭包作用域的线程,从而使得安全地借用局部变量成为可能。 最好通过示例来说明其工作原理:

1
2
3
4
5
6
7
8
9
10
11
12
let numbers = vec![1, 2, 3];

thread::scope(|s| { 1
s.spawn(|| { 2
println!("length: {}", numbers.len());
});
s.spawn(|| { 2
for n in &numbers {
println!("{n}");
}
});
}); 3
1 我们使用闭包调用 std::thread::scope 函数。我们的闭包直接执行并获得一个参数 s,表示作用域。
2 我们使用 s 来生成线程。闭包可以借用像 numbers 这样的局部变量。
3 当作用域结束时,所有尚未加入的线程将自动加入。

这种模式保证了在作用域中生成的线程都不会超出该作用域的生命周期。因此,这个有范围限制的 spawn 方法没有对其参数类型施加 static 约束,允许我们引用任何东西只要它在作用域内存在,比如 numbers。 在上面的例子中,两个新线程都同时访问 numbers。这是可以的,因为它们(以及主线程)都没有修改它。如果我们将第一个线程更改为修改 numbers ,就像下面所示那样,则编译器将不允许我们再次生成使用 numbers 的另一个线程:

1
2
3
4
5
6
7
8
9
10
let mut numbers = vec![1, 2, 3];

thread::scope(|s| {
s.spawn(|| {
numbers.push(1);
});
s.spawn(|| {
numbers.push(2); // Error!
});
});

确切的错误信息取决于 Rust 编译器的版本,因为它经常改进以产生更好的诊断结果,但尝试编译上述代码将导致类似于以下内容的结果:

1
2
3
4
5
6
7
8
9
10
11
12
error[E0499]: cannot borrow `numbers` as mutable more than once at a time
--> example.rs:7:13
|
4 | s.spawn(|| {
| -- first mutable borrow occurs here
5 | numbers.push(1);
| ------- first borrow occurs due to use of `numbers` in closure
|
7 | s.spawn(|| {
| ^^ second mutable borrow occurs here
8 | numbers.push(2);
| ------- second borrow occurs due to use of `numbers` in closure

The Leakpocalypse

在 Rust 1.0 之前,标准库有一个名为 std::thread::scoped 的函数,它可以直接生成线程,就像 std::thread::spawn 一样。它允许非 'static 捕获,因为它返回的是一个 JoinGuard 而不是 JoinHandle,在其被丢弃时会加入线程。任何借用的数据只需要比这个 JoinGuard 存活时间更长即可。这似乎很安全,只要确保在某个时刻丢弃了该 JoinGuard.

就在 Rust 1.0 发布之前,在“泄漏启示录”中逐渐清楚地表明无法保证某些东西将被丢弃。有许多方法可以使人们忘记某些东西或者泄漏掉而没有释放它。

最终得出结论:(安全)接口设计不能依赖于对象总是在生命周期结束时被删除的假设。泄漏对象可能合理地导致更多对象泄漏(例如泄漏 Vec 将同时泄漏其元素),但可能不会导致未定义行为。由于这个结论,“std :: thread :: scoped” 不再被认为是安全的,并从标准库中移除了。“std :: mem :: forget”也从“unsafe”函数升级到了safe函数以强调遗忘(或泄漏)总是有可能的。

直到 Rust 1.63,才添加了一个新的 std::thread::scope 函数,其设计不依赖于 Drop 来保证正确性。

共享所有权和引用计数

到目前为止,我们已经学习了如何使用 move 闭包(”Rust 中的线程”)将值的所有权转移给线程,并从寿命更长的父线程中借用数据(”作用域线程”)。当在两个不保证互相存活的线程之间共享数据时,它们都不能成为该数据的所有者。任何在它们之间共享的数据都需要与最长寿命的线程一样长。

Statics(静态)

有几种方法可以创建一个不属于单个线程的东西。最简单的方法是使用“静态”值,它由整个程序而不是单个线程“拥有”。在以下示例中,两个线程都可以访问X,但没有一个线程拥有它:

1
2
3
4
static X: [i32; 3] = [1, 2, 3];

thread::spawn(|| dbg!(&X));
thread::spawn(|| dbg!(&X));

static项目具有恒定的初始化程序,永远不会被丢弃,并且在程序的主函数甚至开始之前就已经存在。每个线程都可以借用它,因为它保证始终存在。

Leaking(泄露)

另一种共享所有权的方式是通过泄露分配。使用Box::leak,可以释放对Box的所有权,并承诺永远不会丢弃它。从那时起,该 Box 将永远存在,没有所有者,允许任何线程在程序运行期间借用它。

1
2
3
4
let x: &'static [i32; 3] = Box::leak(Box::new([1, 2, 3]));

thread::spawn(move || dbg!(x));
thread::spawn(move || dbg!(x));

move闭包可能会让我们觉得我们正在将所有权移动到线程中,但仔细查看x的类型会发现,我们只是给线程一个对数据的引用

引用是Copy的,这意味着当您“移动”它们时,原始副本仍然存在,就像整数或布尔值一样。

请注意,static生命周期并不意味着该值从程序开始就一直存在,而只是表示它在程序结束之前一直存在。过去根本不重要。

泄漏Box的缺点是我们正在泄漏内存。 我们分配了某些东西,但从未删除和释放它。 如果这种情况仅发生有限次数,则可以接受。 但如果我们继续这样做,则程序将慢慢耗尽内存。

Reference Counting(引用计数)

为了确保共享数据被丢弃和释放,我们不能完全放弃其所有权。相反,我们可以共享所有权。通过跟踪拥有者的数量,我们可以确保只有在没有剩余拥有者时才会删除该值。

Rust标准库通过std::rc::Rc类型提供此功能,简称“引用计数”。它与Box非常相似,但是克隆它不会分配任何新内容,而是增加存储在所包含值旁边的计数器。原始的和克隆的 Rc 都将指向同一内存分配; 它们共享所有权

1
2
3
4
5
6
use std::rc::Rc;

let a = Rc::new([1, 2, 3]);
let b = a.clone();

assert_eq!(a.as_ptr(), b.as_ptr()); // Same allocation!

放弃一个 Rc 将会减少计数器。只有最后的 Rc,它将看到计数器降至零,才是放弃和释放包含数据的那个。

然而,如果我们尝试将一个 Rc 发送到另一个线程中,则会遇到以下编译器错误:

1
2
3
4
error[E0277]: `Rc` cannot be sent between threads safely
|
8 | thread::spawn(move || dbg!(b));
| ^^^^^^^^^^^^^^^

事实证明,Rc 不是线程安全的(更多信息请参见“Thread Safety: Send and Sync”)。如果多个线程都有一个指向相同分配的 Rc,它们可能会尝试同时修改引用计数器,这可能会导致不可预测的结果。 相反,我们可以使用 std::sync::Arc,它代表”原子引用计数”。它与 Rc 相同,只是保证对引用计数进行的修改是不可分割的原子操作,使其能够安全地与多个线程一起使用(更多信息请参见第2章 )。

1
2
3
4
5
6
7
use std::sync::Arc;

let a = Arc::new([1, 2, 3]); 1
let b = a.clone(); 2

thread::spawn(move || dbg!(a)); 3
thread::spawn(move || dbg!(b)); 3
1 我们将一个数组和一个引用计数器放在新的分配中,该计数器从一开始。
2 克隆 Arc 将引用计数增加到两个,并为我们提供了第二个指向同一分配的 Arc
3 两个线程都通过自己的 Arc 访问共享数组。当它们丢弃其 Arc 时,都会减少引用计数器。最后一个丢弃其 Arc 的线程将看到计数器降至零,并将是释放和回收数组的线程。

克隆命名

不得不为每个 Arc 的克隆体分配一个不同的名称,这可能会使代码变得混乱且难以跟踪。虽然每个 Arc 的克隆体都是单独的对象,但每个克隆体代表着相同的共享值,这并不能通过给它们命名来很好地反映出来。

Rust 允许(并鼓励)您通过定义具有相同名称的新变量来 遮盖 变量。如果在同一作用域中执行此操作,则原始变量将无法再被命名。但是通过打开一个新作用域,在该作用域内可以使用类似于 let a = a.clone(); 这样的语句重复使用相同的名称,并在作用域外保留原始变量。

通过将闭包包装在新范围内(使用 {}),我们可以在将其移动到闭包中之前对变量进行克隆,而无需重新命名它们。

| let a = Arc::new([1, 2, 3]); let b = a.clone(); thread::spawn(move || { dbg!(b); }); dbg!(a); The clone of the Arc lives in the same scope. Each thread gets its own clone with a different name. | let a = Arc::new([1, 2, 3]); thread::spawn({ let a = a.clone(); move || { dbg!(a); } }); dbg!(a);The clone of the Arc lives in a different scope. We can use the same name in each thread. |
| ———————————————————— | ———————————————————— |
| | |

因为所有权是共享的,引用计数指针(Rc<T>Arc<T>)与共享引用(&T)具有相同的限制。它们不会给您对其包含值的可变访问权限,因为该值可能同时被其他代码借用。 例如,如果我们尝试对 Arc<[i32]> 中的整数切片进行排序,则编译器将阻止我们这样做,并告诉我们不允许修改数据:

1
2
3
4
error[E0596]: cannot borrow data in an `Arc` as mutable
|
6 | a.sort();
| ^^^^^^^^

Borrowing and Data Races(借用和数据竞争)

在 Rust 中,值可以通过两种方式进行借用:

  • 不可变借用 使用 & 借用某个东西会得到一个 不可变引用。这样的引用是可以复制的。对其所指向数据的访问在所有此类引用副本之间共享。正如名称所示,编译器通常不允许您通过这样的引用来 改变 某些东西,因为那可能会影响当前正在借用相同数据的其他代码。

  • 可变借用 使用 &mut 借出某个东西会得到一个 可变引用。可变借贷保证它是该数据唯一活动租赁者。这确保了修改数据不会更改其他代码正在查看的任何内容。

    这两个概念结合起来完全防止了 数据竞争:其中一个线程在修改数据而另一个线程同时访问它的情况。 数据竞争通常是 未定义行为,这意味着编译器无需考虑这些情况,并且只需假定它们不会发生即可。

    为了澄清其含义,让我们看一个例子,在此例中编译器可以使用借贷规则做出有益假设:

1
2
3
4
5
6
7
8
fn f(a: &i32, b: &mut i32) {
let before = *a;
*b += 1;
let after = *a;
if before != after {
x(); // never happens
}
}

在这里,我们获得了一个不可变的整数引用,并存储了 b 所引用的整数在增加之前和之后的值。编译器可以自由地假设借用和数据竞争方面的基本规则得到遵守,这意味着 b 不可能指向与 a 相同的整数。实际上,在 a 借用该整数期间,程序中没有任何东西可以对其进行可变借用。因此,编译器可以轻松地推断出 *a 不会改变,并且 if 语句条件永远不会为真,并完全将调用 x 的代码作为优化从程序中删除。 除非使用 unsafe 块禁止一些编译器安全检查,否则无法编写破坏 Rust 编译器假设的程序。

Undefined Behavior(未定义行为)

像C、C++和Rust这样的语言有一组需要遵循的规则,以避免出现所谓的未定义行为。例如,Rust的一个规则是任何对象都不能有多个可变引用。

在Rust中,只有使用unsafe代码时才可能违反这些规则。 “不安全”并不意味着代码是错误或永远不安全使用,而是编译器没有验证代码是否安全。如果代码确实违反了这些规则,则称其为不完整

编译器可以假设这些规则从未被破坏,而无需检查。当破坏时,会导致所谓的未定义行为,我们必须尽一切努力避免它。如果我们允许编译器做出实际上并非如此的假设,则很容易对您代码中其他部分产生更多错误结论,并影响整个程序。

作为具体示例,请看下面一个小片段,在其中使用了slice上的get_unchecked方法:

1
2
let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };

get_unchecked 方法可以通过索引获取切片中的元素,就像 a[index] 一样,但是允许编译器假设索引始终在边界内,而不进行任何检查。 这意味着在此代码段中,因为a的长度为3,编译器可以假设index小于三。我们需要确保它的假设成立。 如果我们打破了这个假设,例如将index设置为3,则可能会发生任何事情。它可能导致从存储在a右侧字节中的内存读取任何内容。它可能导致程序崩溃。它可能最终执行某些完全无关的程序部分。它会造成各种混乱。 也许令人惊讶的是,在未定义行为甚至可以 “穿越时间”,影响到先前代码中出现问题。要理解如何发生这种情况,请想象我们之前有一个 match 语句:

1
2
3
4
5
6
7
8
match index {
0 => x(),
1 => y(),
_ => z(index),
}

let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };

由于不安全的代码,编译器可以假设 index 只会是 0、1 或 2。它可能会逻辑上得出结论,我们 match 语句的最后一个分支只能匹配到数字 2,并且因此 z 函数只被调用为 z(2)。这个结论不仅可以优化 match,还可以优化 z 函数本身。这包括抛弃未使用的代码部分。

如果我们将其执行时传入了一个值为3 的index,则程序可能尝试执行已经被优化掉的部分,导致完全无法预测的行为,在我们到达最后一行的 unsafe 块之前就发生了。就像那样,未定义行为可能通过整个程序向前和向后传播,并以通常非常意外的方式表现出来。

在调用任何一个带有 unsafe 标记函数时,请仔细阅读其文档并确保您充分理解其安全要求:作为调用者需要遵守哪些假设才能避免未定义行为。

Interior Mutability(内部可变性)

在前面的章节中介绍了借用规则,这些规则很简单,但是在涉及多个线程时可能会非常限制——特别是当没有数据可以被多个线程访问和修改时。遵循这些规则使得线程之间的通信极其有限且几乎不可能。

幸运的是,有一个逃生口:内部可变性。具有内部可变性的数据类型略微弯曲了借用规则。在某些条件下,这些类型可以允许通过“不可变”引用进行突变。

在[“引用计数”](#Reference Counting(引用计数))一节中,我们已经看到了一个涉及内部可变性的微妙示例。无论是否存在多个克隆使用相同的引用计数器,RcArc都会突变引用计数器。

一旦涉及到具有内部可变类型,则调用引用为“不可变”或“可变”的术语将会令人困惑和不准确,因为某些东西可以通过两者进行改动。更准确地说,“共享”和“独占”才是更精确的术语:共享引用&T)可以复制并与其他人分享,而独占引用&mut T)则保证它是该 T 的唯一 独占借用。对于大多数类型,共享引用不允许修改,但也有例外情况。由于在本书中我们将主要使用这些异常情况,因此在本书的其余部分中我们将使用更准确的术语。

请记住,内部可变性仅弯曲了共享借用规则以允许在共享时进行突变。它并不改变任何关于独占借用的事情。无论是否具有内部可变性,在某个地方导致超过一个活动独占引用的不安全代码总是会调用未定义行为。

让我们看一下几种具有内部可变性的类型及其如何通过共享引用允许突变而不会导致未定义行为。

Cell

std::cell::Cell<T>简单地包装了一个 T ,但允许通过共享引用进行突变。为避免未定义行为,它只允许您复制值(如果 TCopy),或者整体替换另一个值。此外,它只能在单个线程中使用。 接下来看一个类似前面章节示例的示例,但这次使用的是 Cell<i32> 而不是 i32

1
2
3
4
5
6
7
8
9
10
use std::cell::Cell;

fn f(a: &Cell<i32>, b: &Cell<i32>) {
let before = a.get();
b.set(b.get() + 1);
let after = a.get();
if before != after {
x(); // might happen
}
}

与上次不同,现在if条件可能为真。因为Cell<i32>具有内部可变性,编译器不能再假设只要我们拥有共享引用就不会改变其值。 ab都可能引用相同的值,这样通过b进行突变也可能影响到 a. 但是它仍然可以假定没有其他线程同时访问单元格。

对于一个 Cell, 其限制并不总是易于使用。由于它无法直接让我们借用其持有的值,因此我们需要将一个值移出(留下另一个东西),修改它,然后将其放回以更改其内容:

1
2
3
4
5
fn f(v: &Cell<Vec<i32>>) {
let mut v2 = v.take(); // Replaces the contents of the Cell with an empty Vec
v2.push(1);
v.set(v2); // Put the modified Vec back
}

RefCell(引用计数单元)

与常规的 Cell 不同,std::cell::RefCell 允许您借用其内容,但会带来一些运行时成本。 RefCell<T> 不仅保存了一个 T,还保存了一个计数器来跟踪任何未完成的借用。如果在它已经被可变地借用(或反之亦然)时尝试进行借用,则会引发 panic,从而避免了未定义的行为。就像 Cell 一样,只能在单个线程中使用 RefCell。 通过调用 borrowborrow_mut 来借用 RefCell 的内容:

1
2
3
4
5
use std::cell::RefCell;

fn f(v: &RefCell<Vec<i32>>) {
v.borrow_mut().push(1); // We can modify the `Vec` directly.
}

虽然 CellRefCell 可以非常有用,但当我们需要在多个线程中执行某些操作时,它们变得相当无用。因此,让我们继续介绍与并发相关的类型。

Mutex and RwLock(互斥锁和读写锁)

一个 RwLock读写锁RefCell 的并发版本。一个 RwLock<T> 持有一个类型为 T 的值,并跟踪任何未完成的借用。然而,与 RefCell 不同的是,在出现冲突借用时它不会 panic。相反,它会阻塞当前线程——将其置于睡眠状态——等待冲突借用消失。我们只需耐心地等待轮到我们使用数据,其他线程使用完后再进行。

从一个 RwLock 中获取内容被称为 锁定 。通过 锁定 它,我们暂时阻止了并发的冲突借用,使得我们可以在不引起数据竞争的情况下进行借用。

一个 Mutex 非常类似,但概念上稍微简单一些。它不像 RwLock 一样跟踪共享和独占式借用的数量,而是仅允许独占式借用。

关于这些类型更详细的信息,请参见 “锁:Mutexes 和 RwLocks”

Atomics

原子类型代表了Cell的并发版本,是第2章和第3章的主要内容。与Cell一样,它们通过让我们整体复制值来避免未定义行为,而不直接借用其内容。 但与Cell不同的是,它们不能是任意大小。因此,并没有通用的适用于任何T类型的Atomic类型,而只有特定的原子类型(例如AtomicU32和AtomicPtr)。可用哪些取决于平台,因为它们需要处理器支持以避免数据竞争。(我们将在第7章中深入探讨这个问题)。 由于尺寸非常有限,在线程之间共享信息时原子通常不会直接包含所需信息。相反,它们经常被用作工具来使得可以在线程之间共享其他更大型的东西。当原子被用来描述其他数据时,则可能变得异常复杂。

UnsafeCell(unsafe单元)

一个 UnsafeCell 是内部可变性的基本构建块。

一个 UnsafeCell<T> 包装了一个 T,但没有任何条件或限制来避免未定义行为。相反,它的 get() 方法只是给出了它包装的值的原始指针,这个指针只能在 unsafe 块中有意义地使用。它把如何使用留给用户,在不引起任何未定义行为的情况下使用。

最常见的情况是,一个 UnsafeCell 不会直接使用,而是被包含在另一种类型中,并通过有限接口提供安全性,例如 Cell 或者 Mutex. 所有具有内部可变性(包括上面讨论过的所有类型)都建立在 UnsafeCell 的基础之上。

线程安全:发送和同步

在本章中,我们看到了几种不是线程安全的类型,这些类型只能在单个线程上使用,例如RcCell等。由于需要这种限制以避免未定义行为,因此编译器需要理解并检查您是否可以使用这些类型而无需使用“unsafe”块。

语言使用两个特殊的trait来跟踪哪些类型可以安全地跨越线程使用:

  • Send

    如果一个值得所有权可以转移到另一个线程,则该类型是“Send”。例如,“Arc”是“Send”,但“Rc”不是。

  • Sync

    如果一个共享引用到该类型,“&T”,也是“Send”,则该类型就是“Sync”。例如,“i32” 是 “Sync”,但 “Cell” 不是。(然而,“Cell” 是 “Send”的。) 所有基本数据类型如 i32, bool, 和 str 都同时实现了 SendSync. 这两个 trait 都属于 自动 trait ,这意味着它们会根据字段自动实现你的自定义结构体。如果一个结构体的所有字段都满足了 SendSync, 则该结构体本身也具有相应特性。 要退出其中任何一项,请向您的类型添加未实现该 trait 的字段。为此,通常会用到特殊的 std::marker::PhantomData 类型。该类型被编译器视为 T,但实际上在运行时并不存在。它是一个零大小的类型,不占用空间。 让我们看一下以下结构体:

1
2
3
4
5
6
use std::marker::PhantomData;

struct X {
handle: i32,
_not_sync: PhantomData<Cell<()>>,
}

在这个例子中,如果 handle 是它唯一的字段,则 X 将同时是 SendSync。然而,我们添加了一个大小为零的 PhantomData<Cell<()>> 字段,该字段被视为一个 Cell<()>。由于 Cell<()> 不是可同步的(not Sync),因此 X 也不是可同步的(not Sync)。但它仍然是可发送的(Send),因为它所有的字段都实现了 Send。

原始指针(*const T 和 *mut T`)既不是 Send 也不是 Sync,因为编译器对其所代表内容知之甚少。

选择加入任何其他 trait 的方式相同;使用 impl 块来实现您类型上要用到的 trait:

1
2
3
4
5
6
struct X {
p: *mut i32,
}

unsafe impl Send for X {}
unsafe impl Sync for X {}

请注意,实现这些特性需要使用 unsafe 关键字,因为编译器无法检查它是否正确。这是您向编译器做出的承诺,它只能信任您。 如果您尝试将某个东西移动到另一个不是 Send 的线程中,则编译器会礼貌地阻止您这样做。以下是一个小例子来演示:

1
2
3
4
5
6
fn main() {
let a = Rc::new(123);
thread::spawn(move || { // Error!
dbg!(a);
});
}

在这里,我们试图将一个 Rc<i32> 发送到一个新线程中,但是与 Arc<i32> 不同,Rc<i32> 没有实现 Send。 如果我们尝试编译上面的示例,则会遇到类似于以下内容的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
error[E0277]: `Rc<i32>` cannot be sent between threads safely
--> src/main.rs:3:5
|
3 | thread::spawn(move || {
| ^^^^^^^^^^^^^ `Rc<i32>` cannot be sent between threads safely
|
= help: within `[closure]`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
--> src/main.rs:3:19
|
3 | thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`

thread::spawn”函数要求其参数为“Send”,而闭包只有在其所有捕获的内容都是“Send”时才是“Send”的。如果我们尝试捕获某些不是“Send”的东西,就会被发现错误,从而保护我们免受未定义行为的影响。

锁定:互斥锁和读写锁

在线程之间共享(可变)数据的最常用工具是互斥锁,简称“mutex”。互斥锁的作用是通过暂时阻止试图同时访问它的其他线程来确保线程对某些数据具有独占访问权。

从概念上讲,互斥锁只有两种状态:已锁定和未锁定。当一个线程将一个未锁定的互斥锁加锁时,该互斥锁被标记为已锁定,并且该线程可以立即继续执行。然后,当另一个线程尝试去获取已经被加了锁的互斥量时,这个操作就会阻塞。在等待解除阻塞期间,该线程将进入睡眠状态。仅在已经加了锁的情况下才能解除阻塞,并且应由相同的线程进行解除阻塞操作。如果其他线程正在等待获取该互斥量,则解除阻塞将唤醒其中一个等待中的线程以再次尝试获取并继续其任务。

使用mutex保护数据只需所有参与者达成一致意见:他们只会在拥有mutex时才能访问数据。这样一来,任何两个或多个不同的进城都无法同时访问该数据,从而避免了数据竞争。

Rust的互斥锁

Rust标准库通过std::sync::Mutex<T>提供此功能。它是针对类型T的泛型,该类型是互斥锁所保护数据的类型。通过将这个T作为互斥锁的一部分,数据只能通过互斥锁访问,从而实现了安全接口,并确保所有线程都遵守协议。

为确保被锁定的互斥锁只能由锁定它的线程解除锁定,它没有一个名为“unlock()” 的方法。相反,其“lock()”方法返回一种特殊类型称为“MutexGuard”。该guard表示我们已经成功地获取了互斥锁。 它通过 DerefMut trait 表现得像独占引用, 使我们可以独占地访问受到mutex保护的数据。释放guard时会解除mutex上的加锁状态。当我们放弃使用guard时,就失去了访问数据的权利,“Drop” guard 的实现将解开mutex。

下面看一个例子来看看如何在实践中使用mutex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::sync::Mutex;

fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}

在这里,我们有一个 Mutex<i32>,它是保护整数的互斥锁,并且我们生成十个线程来每次将整数增加一百次。每个线程都会首先锁定互斥锁以获取 MutexGuard,然后使用该 guard 访问并修改整数。当变量超出作用域时,guard 会被隐式释放。

在线程完成后,我们可以通过 into_inner() 安全地从整数中删除保护。into_inner() 方法拥有互斥锁的所有权,这保证了没有其他东西可以再引用该互斥锁了,因此不需要进行加锁操作。

即使增量按照步长为1发生,在观察整数的线程只能看到100的倍数值, 因为它只能在解除互斥锁时查看该整数。有效地说, 多个一百次递增现在成为单个不可分割 - 原子 - 操作得益于互斥体。

要清楚地看到互斥体的效果,请让每个线程等待一秒钟才解除互斥体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::time::Duration;

fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
thread::sleep(Duration::from_secs(1)); // New!
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}

现在运行程序,你会发现它需要大约10秒才能完成。每个线程只等待一秒钟,但互斥锁确保同一时间只有一个线程可以这样做。

如果我们在睡眠一秒钟之前放弃保护(因此解锁互斥锁),我们将看到它并行发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let n = Mutex::new(0);
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
let mut guard = n.lock().unwrap();
for _ in 0..100 {
*guard += 1;
}
drop(guard); // New: drop the guard before sleeping!
thread::sleep(Duration::from_secs(1));
});
}
});
assert_eq!(n.into_inner().unwrap(), 1000);
}

通过这个改变,该程序现在只需要大约一秒钟的时间,因为现在10个线程可以同时执行它们的一秒睡眠。这表明了保持互斥锁锁定时间尽可能短的重要性。将互斥锁锁定时间超过必要时间会完全抵消并行性带来的任何好处,有效地强制所有事情按顺序发生。

锁中毒

上面示例中的 unwrap() 调用与 锁中毒 有关。

在 Rust 中,当一个线程在持有锁时发生 panic 时,Mutex 就会被标记为 已中毒。这种情况下,Mutex 不再被锁定,但调用其 lock 方法将导致返回一个 Err 来指示它已经被中毒了。

这是一种机制来防止保护互斥量所保护的数据处于不一致状态。在我们上面的示例中,如果一个线程在递增整数少于100次后发生 panic,则互斥量将解锁,并且整数将处于意外状态,在那里它不再是100的倍数,可能破坏其他线程所做出的假设。自动标记互斥量为已污染可以强制用户处理此可能性。

对受感染的互斥体调用 lock() 仍然会锁定该互斥体。由 lock() 返回的 Err 包含了 MutexGuard, 允许我们根据需要纠正不一致状态。

虽然看起来像是强大机制, 但实际上从潜在不一致状态恢复并不常见。大多数代码要么忽略 poison 或使用 unwrap() 在 lock 被污染时 panic,从而将 panic 传播给互斥量的所有用户。

MutexGuard 的生命周期

虽然隐式地丢弃 guard 以解锁互斥体很方便,但有时会导致微妙的意外。如果我们使用 let 语句为 guard 分配一个名称(就像上面的示例中一样),那么相对来说比较容易看出它何时被丢弃,因为局部变量在定义它们的作用域结束时被丢弃。尽管如此,不显式地放弃 guard 可能会导致保持互斥体锁定时间超过必要时间,在上面的示例中已经演示了这一点。

在不给 guard 分配名称的情况下使用它也是可能的,并且有时非常方便。由于 MutexGuard 表现得像受保护数据的独占引用,因此我们可以直接使用它而无需先将其分配给一个变量名。例如,如果您有一个 Mutex<Vec<i32>>,则可以在单个语句中锁定互斥体、将项目推入到 Vec 中并再次解锁互斥体:

1
list.lock().unwrap().push(1);

在较大的表达式中产生的任何临时变量,例如lock()返回的保护条件,都将在语句结束时被删除。虽然这似乎很明显和合理,但它会导致一个常见陷阱,通常涉及到matchif let或者 while let语句。以下是一个遇到此问题的示例:

1
2
3
if let Some(item) = list.lock().unwrap().pop() {
process_item(item);
}

如果我们的意图是锁定列表、弹出一个项目、解锁列表,然后在解锁列表之后处理该项目,那么我们在这里犯了一个微妙但重要的错误。临时保护程序直到整个 if let 语句结束才会被释放,这意味着我们在处理该项时不必要地持有锁。

令人惊讶的是,在类似于此示例中的类似 if 语句中不会发生这种情况:

1
2
3
if list.lock().unwrap().pop() == Some(1) {
do_something();
}

这里,在执行 if 语句体之前,临时守卫确实会被删除。原因是常规 if 语句的条件始终是一个普通布尔值,不能借用任何东西。没有理由将从条件到语句末尾的临时变量寿命延长。然而,对于 if let 语句可能不是这种情况。例如,如果我们使用了 front() 而不是 pop() ,则 item 将从列表中借用,这使得必须保留守卫。由于借用检查器只是一个检查,并不影响事物何时以及以什么顺序被删除,即使我们使用了 pop() ,同样也会发生这种情况。

我们可以通过将弹出操作移动到单独的 let 语句中来避免这种情况。然后在该声明结束之前放弃 guard,在 if let 中:

1
2
3
4
let item = list.lock().unwrap().pop();
if let Some(item) = item {
process_item(item);
}

读者-写者锁

互斥锁只关心独占访问。即使我们只想查看数据并且共享引用(&T)已经足够,MutexGuard也会为我们提供对受保护数据的独占引用(&mut T)。

读者-写者锁是互斥锁的稍微复杂一点的版本,它理解排他和共享访问之间的区别,并可以提供任何一种类型。它有三个状态:未加锁、由单个写入器(用于独占访问)加锁以及由任意数量的读取器(用于共享访问)加锁。它通常用于多线程频繁读取但偶尔更新数据。

Rust标准库通过 std::sync::RwLock<T> 类型提供了这种类型的锁。它与标准 Mutex 的工作方式类似,但其接口大部分被拆分成两部分。它具有一个 read() 和一个 write() 方法来进行阻塞式地以读或写模式进行加锁操作。 它带有两种警戒类型,一种是针对读取器而言,另一种则是针对编写器而言:RwLockReadGuardRwLockWriteGuard. 前者仅实现了Deref以表现为受保护数据的共享引用,而后者还实现了DerefMut以表现为独占引用。

它有效地是 RefCell 的多线程版本,动态跟踪引用数量以确保借用规则得到遵守。

无论是 Mutex<T> 还是 RwLock<T> 都需要T为Send,因为它们可以被用于将T发送到另一个线程。此外,RwLock<T> 还要求 T 也实现 Sync ,因为它允许多个线程持有对受保护数据的共享引用(&T)。 (严格来说,您可以创建一个不满足这些要求的 T 的锁定器,但您将无法在线程之间共享该锁定器本身不会实施同步)。

Rust标准库仅提供一种通用目的的 RwLock 类型,但其实现取决于操作系统。读者-写者锁实现之间存在许多微妙差异。当等待编写器时大多数情况下都会阻止新读取器加入即使已经处于读取状态下也一样。这样做是为了防止编写程序饥饿(writer starvation),即许多读取器集体阻止锁从未解除并永远不允许任何编写程序更新数据的情况。

其他语言中的互斥锁

Rust 的标准 MutexRwLock 类型与其他语言(如 C 或 C++)中的类型有所不同。

最大的区别在于 Rust 的 Mutex<T> 包含 它所保护的数据。例如,在 C++ 中,std::mutex 并不包含它所保护的数据,也不知道它正在保护什么。这意味着用户需要记住哪些数据受到保护以及由哪个互斥锁进行了保护,并确保每次访问“受保护”数据时都正确地锁定相应的互斥锁。当阅读涉及其他语言中互斥锁代码或与不熟悉 Rust 的程序员交流时,这一点很有用。一个 Rust 程序员可能会谈论“互斥体内部的数据”,或者说像“将其包装在一个互斥体中”,这对那些只熟悉其他语言中互斥体而非 Rust 时可能会感到困惑。

如果您真正需要一个独立于任何内容且并未包含任何东西的 mutex,例如用于保护某些外部硬件,则可以使用 Mutex<()>。但即使是在这种情况下,您也最好定义一个(可能为零大小)类型来接口该硬件,并将其包装在 Mutex 中。这样,您仍然需要在与硬件交互之前锁定互斥锁。

等待:停车和条件变量

当数据被多个线程改变时,有许多情况需要等待某些事件发生,等待一些关于数据的条件成为真。例如,如果我们有一个互斥锁来保护 Vec ,我们可能希望等到它包含任何内容。

虽然互斥锁允许线程等待直到它解锁,但它不提供等待任何其他条件的功能。如果只有互斥锁可用,我们将不得不继续锁定互斥锁以重复检查是否已经在 Vec 中包含了任何东西。

线程停车

一种从另一个线程中等待通知的方法称为线程停车。一个线程可以 park 自己,这会使其进入睡眠状态,并阻止其消耗任何 CPU 周期。然后另一个线程可以 unpark 已经停放的线程并唤醒它。

通过 std::thread::park() 函数可以实现线程停车。对于取消停放,则调用表示要取消停放的线 程序所代表的 Thread 对象上的 unpark() 方法即可完成操作。这样的对象可以通过由 spawn 返回的 join 句柄获得或者通过当前正在运行该函数本身获取(使用 std :: thread :: current())。

下面我们来看一个使用互斥锁在两个线程之间共享队列的示例。在下面的示例中,新生成的线程将从队列中消耗项目,而主线程将每秒向队列插入一个新项目。当队列为空时,使用线程停车使得消费者线程等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::collections::VecDeque;

fn main() {
let queue = Mutex::new(VecDeque::new());

thread::scope(|s| {
// 消费线程
let t = s.spawn(|| loop {
let item = queue.lock().unwrap().pop_front();
if let Some(item) = item {
dbg!(item);
} else {
thread::park();
}
});

// 生产线程
for i in 0.. {
queue.lock().unwrap().push_back(i);
t.thread().unpark();
thread::sleep(Duration::from_secs(1));
}
});
}

消费线程运行一个无限循环,从队列中弹出项目并使用“dbg”宏显示它们。当队列为空时,它停止并使用“park()”函数休眠。如果被唤醒,则“park()”调用返回,“loop”继续,再次从队列中弹出项目直到为空为止。

生产线程每秒钟生成一个新数字,并将其推入队列中。每次添加项时,它都会在引用消费线程的Thread对象上使用unpark()方法来取消挂起。这样,消费线程就会被唤醒以处理新元素。

这里需要注意的一点是:即使我们删除了parking操作,该程序仍然在理论上是正确的但效率低下。这很重要,因为”park()”不能保证只有匹配的”unpark()”才能返回。虽然罕见, 但可能存在虚假唤醒 。我们的示例可以很好地处理这个问题, 因为消费者线程将锁定队列、检查是否为空, 然后直接解锁并重新进入休眠状态。

线程停车的一个重要属性是,在线程自己停车之前调用unpark()不会丢失请求. 请求取消挂起仍然记录在案,并且下一次尝试让该线程进入休眠状态时清除该请求,并直接继续而不实际进入休眠状态。为了看到这对正确操作的关键性,让我们通过两个线程执行的步骤可能的排序来进行说明:

  1. 消费线程(称之为C)锁定队列。
  2. C尝试从队列中弹出一个项目,但它是空的,结果返回“None”。
  3. C解锁队列。
  4. 生产线程(我们将其称为P)锁定队列。
  5. P将新项推送到队列中。
  6. P再次解锁该队列。
  7. P调用unpark()通知C有新项可用.
  8. C调用park()进入休眠状态, 等待更多项目。

虽然在第3步释放队列和第8步停车之间只有非常短暂的时刻,但第4至7步可能会在该时刻发生。如果’unpark()’如果线程没有挂起,则什么也不做,则通知将丢失。即使在队列中有一个项目, 消费者线程仍然会等待。由于取消挂起请求被保存以供未来调用’park()’, 我们无需担心这一点。

但是, 取消挂起请求并不堆叠。连续两次调用’unpark()’, 然后再连续两次调用’park()’, 仍然导致该线程进入睡眠状态。第一个’park()’清除请求并直接返回,但第二个则像往常一样进入睡眠状态。

这意味着在上面的示例中,重要的是只有在看到队列为空时才将线程挂起,而不是在处理每个项目后都将其挂起。虽然由于巨大(一秒钟)的休眠时间,在此示例中极不可能发生, 但多次’unpark()’调用可能会唤醒仅单个’park()’调用.

不幸的是,这意味着如果在队列被锁定并清空之前,park()返回后立即调用了unpark(),则unpark()调用是不必要的但仍会导致下一个park()调用立即返回。这将导致(空)队列多次被锁定和解锁。虽然这不影响程序的正确性,但它确实影响了其效率和性能。

对于像我们示例中那样简单的情况,此机制运作良好,但当事情变得更加复杂时很快就会崩溃。例如,如果我们有多个消费者线程从同一队列中取出项目,则生产者线程将无法知道哪个消费者正在等待并应该唤醒。生产者将必须准确地知道何时有消费者在等待以及它正在等待什么条件。

条件变量

条件变量是等待由互斥锁保护的数据发生某些事情的更常用选项。它们有两个基本操作:waitnotify。线程可以在条件变量上等待,然后当另一个线程通知同一条件变量时,它们可以被唤醒。多个线程可以在同一条件变量上等待,并且通知可以发送给一个等待线程或所有等待线程。

这意味着我们可以为特定事件或我们感兴趣的条件创建一个条件变量,例如队列非空,并在该条件下进行等待。任何导致该事件或条件发生的线程都会通知该条件变量,而无需知道哪些或多少个线程对该通知感兴趣。

为了避免在解锁互斥锁并等待条件变量之间短暂时刻内错过通知问题, 条件变量提供了一种以原子方式解锁互斥锁并开始等待的方法。这意味着根本没有可能让通知丢失。

Rust标准库提供了std::sync::Condvar作为一个条件变量。其wait方法需要一个证明我们已经锁定互斥锁的 MutexGuard 。它首先解除互斥锁并进入休眠状态,在稍后被唤醒时重新获取互斥锁并返回一个新的MutexGuard(证明互斥锁再次被锁定)。

它有两个通知函数: notify_one用于唤醒等待线程中的一个(如果有),而 notify_all则将它们全部唤醒。

让我们修改我们用于线程停车的示例,改为使用 Condvar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::sync::Condvar;

let queue = Mutex::new(VecDeque::new());
let not_empty = Condvar::new();

thread::scope(|s| {
s.spawn(|| {
loop {
let mut q = queue.lock().unwrap();
let item = loop {
if let Some(item) = q.pop_front() {
break item;
} else {
q = not_empty.wait(q).unwrap();
}
};
drop(q);
dbg!(item);
}
});

for i in 0.. {
queue.lock().unwrap().push_back(i);
not_empty.notify_one();
thread::sleep(Duration::from_secs(1));
}
});

我们不得不改变一些东西:

  • 现在我们不仅有一个包含队列的 Mutex,还有一个用于通信“非空”条件的 Condvar
  • 我们不再需要知道要唤醒哪个线程,因此我们不再存储从 spawn 返回的值。相反,我们通过条件变量使用 notify_one 方法通知消费者。
  • 解锁、等待和重新锁定都由 wait 方法完成。为了能够将守卫传递给 wait 方法,同时在处理项目之前放弃它,我们必须稍微重构控制流程。

现在我们可以生成尽可能多的消费线程,甚至稍后再生成更多线程,而无需更改任何内容。条件变量负责将通知传递给感兴趣的任何线程。

如果我们有一个更复杂的系统,其中不同条件下感兴趣的线程,则可以为每个条件定义一个“Condvar”。例如,我们可以定义一个指示队列非空和另一个指示队列为空的“Condvar”。然后每个线程都会等待与其正在执行任务相关联的条件。

通常,“Condvar”仅与单个“Mutex”一起使用。如果两个线程尝试使用两个不同互斥锁并发地等待条件变量,则可能会导致恐慌。

“Condvar”的缺点是它只能在与“Mutex”一起使用时才有效,但对于大多数用例来说这完全没问题,因为已经用于保护数据了。

thread::park()Condvar::wait() 还具有带时间限制的变体:thread::park_timeout()Condvar::wait_timeout()。这些需要额外提供持续时间(Duration)参数,在此之后应放弃等待通知并无条件唤醒。

总结

  • 多个线程可以在同一个程序中同时运行,并且可以随时生成。
  • 当主线程结束时,整个程序也会结束。
  • 数据竞争是未定义的行为,在 Rust 的类型系统中完全被防止(在安全代码中)。
  • 可以将“Send”数据发送到其他线程,“Sync”数据可在多个线程之间共享。
  • 常规线程可能会一直运行到程序结束,因此只能借用 static 数据,例如静态变量和泄漏分配内存等。
  • 引用计数 (Arc) 可以用于共享所有权,确保数据至少有一个线程正在使用它的时间与其生命周期相同。
  • 作用域限定的线程对于限制线程寿命以允许其借用非 static 数据(例如局部变量)非常有用。
  • &T共享引用。 &mut T独占引用。普通类型不允许通过共享引用进行突变操作。
  • 由于 UnsafeCell 的存在,某些类型具有内部可变性,这使得通过共享引用进行突变成为可能。
  • CellRefCell 是单线程内部可变性的标准类型。原子、互斥锁和读写锁则是它们的多线程等效物品
  • Cell 和原子仅允许整体替换值,而 RefCell、Mutex 和 RwLock 允许通过动态执行访问规则直接修改值。
  • 线程停车可以是等待某些条件的便捷方式。
  • 当条件涉及由 Mutex 保护的数据时,使用 Condvar 比线程停车更方便,并且可能更有效。





关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



从游客角度看“一X游”建设

今年的第八篇,这是一篇邀稿,由文旅行业从业者“心晴”撰写。

​注:本文是“心晴”从游客角度出发撰写的体验和问题分析文章,并不代表“土猛的员外”和​相关公司的观点。

1
一X游:表示一机游、一码游、一键游...

首先,笔者想先以旅游从业者且是旅游信息化从业者的角度,简单讲一下“一X游”的基本情况。2017年是一个分界线,虽然从2014年开始,国内旅游信息化建设大体上也是分为服务、管理和营销三个方向,但服务侧都相对分散,没有形成一个体系,更多侧侧重点在于门票电商。而2017年“一部手机游云南”开始,国内开启了“一X游”模式,从全域的视角整合目的地的文旅资源,包括门票、酒店、民宿、特产等,在类OTA电商功能的基础上,丰富公共服务和体验服务,实现与OTA部分程度上的差异化,服务占据了信息化建设很大一部分比重,直到目前依然是这种形态。

这种形态主导的国内旅游信息化建设面向游客端的服务,一般会划分为游前、游中和游后三个阶段。

  • 游前:提供资讯、咨询、预订等服务,如信息查看、攻略查询、票务/酒店预订等;
  • 游中:提供体验相关的服务,如导游导览、语音讲解、导航等;
  • 游后:提供投诉建议、特产预订等服务。

一般前两者会是重点,下文的游客体验方面的叙述也会从这三方面开启。

其次,再解释一下为什么上文一直用的是“旅游信息化”这个词,而不是“智慧旅游”。因为在笔者看来,“信息化”是一个过程,一个工具或者说一种手段,而“智慧旅游”是一个目标,一个结果或者说是一种状态,而目前国内旅游信息化离这个目标还有很长一段距离。

这里,我先以一个旅游爱好者的身份,分享一下笔者的几次出行经历,看看“一X游”是否有给笔者带来什么便利或者价值

01

在说具体案例之前,笔者可以先简单说说通病,以便大家可以更好地理解后面的具体例子。在2019年和2020年,笔者多次出行,去了国内多个城市。出发之前自然是交通和住宿的安排,也就是游前的过程,这两项笔者都是在OTA上完成的,这是一个养成的习惯。到达目的地后,也就是游中的过程,笔者分别去了这几个城市的著名景区和博物馆。去这些地方游玩时,所有的门票都是到达景区后,在景区门口买的,用的也都是景区自己搭建的自营平台。对于一些文化底蕴很厚重的景区和博物馆,自己看的话无疑是走马观花,就分别现场请了导游(或讲解员),一是没看到线下有这次城市已建的“一X游”线上引导的入口,二是也担心“一X游”的实际服务覆盖能力有限,选择景区自己提供的服务会更精确、响应更及时。

回顾这几次出行的整个过程,完全没有这几个城市“一X游”的使用场景,虽然笔者作为参与者,也参与了其中一个城市“一X游”的建设工作,但是在实际出行的过程中,笔者完全没有想起过要使用它,也就是这个平台没有占据笔者的消费心理。一方面,它确实没有成为笔者的消费习惯,另一方面,笔者在游玩的过程中,没有看到它的入口和****宣传,也就没有启动提示作用,导致它被笔者忽视。

好了,下面举两个具体的例子,但是需要事先说明的是,这两个例子已经是笔者认为国内目前“一X游”的“翘楚”了,我们来看看具体的问题。

02

2021年,“一码游贵州”已经是业内仅次于“一部手机游云南”的存在了,无论是宣传上,还是功能上。但是作为游客的笔者,去贵州的时候与它的交道依然很少。基于各种原因,笔者们去贵州选择的是跟团游,在选定跟团游的行程前,笔者线上在UGC平台做了相关攻略,明确了要去的几个景区,然后在OTA上选择了一个较为合适的线路,并预定了大交通,这是游前的过程。在做这些之前,笔者有意在“一码游贵州”上看了下相关的攻略,可能是习惯问题,平台上的攻略不太能满足需求,所以还是决定在UGC平台上完成这个过程。

由于是跟团游,在贵州出行期间的酒店、交通和门票都是旅行社帮忙解决的,但是每个景区的参观导游是没办法一直在一起的,这个时候笔者有意尝试用“一码游贵州”的导游导览功能,功能页面如下图,这其实是要求笔者要知道自己所在的景点是什么,有目的地选择讲解语音,相对来说不是很便利,也就没有使用。



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外

在贵州游玩真真切切使用到了“一码游贵州”的功能的话其实是在青岩古镇,疫情的关系,虽然不用门票,但是需要预约,预约的平台就是这个。这是整个游中唯一一次真切使用到这个“一码游贵州”平台,而这个使用更大程度上是行政要求(预约)的结果,当然也确实给游客和景区带来便利,节省了排队登记时间,也提升了部分游客体验。

在整个贵州游玩期间,无论是刻意还是实际需求,使用“一码游贵州”平台的机会也是很少的,且体验效果上来说也没有觉得非常的便利或者有效。相较于其他地方的那次出行,在游玩期间看到“一码游贵州”线下入口的机会确定提升了很多,且平台实现了LBS(Location Based Services,基于位置的服务)的应用,在导览的时候可以查看不同景点的距离信息并实现导航,但语音讲解的效果并没有明显的感觉。

03

时间来到2023年,一个意外的契机,笔者去了西双版纳游玩,因为是很临时了,且赶上了那边的旺季,笔者有一部分是时间是自由行,一部分是跟团游。没有太多时间做攻略,笔者只是在社交平台上粗粗地看了几篇游记,选定了去参观游览的景区,以此来安排后面的行程,并在12306上预订了大交通,这是整个游前的过程,快速且随意。

达到目的地后,笔者先是自由行,当时在去中科院植物园和曼听公园之间选择,心里想去的是中科院植物园,但是距离较远,尝试了不同渠道的大巴预订没有成功后,改去了距离较近的曼听公园。

曼听公园的门票也是到了景区后现场门口买的,由于没有做详细的攻略,入园之后完全不知道应该走什么路线,哪个是核心景点,怎么走合适,一时有点蒙。无意间看到了“一部手机游云南的”入口,便尝试着用了下,看到的是下面的页面,导览的跟“一码游贵州”一样,需要知道所在的位置手动播放讲解,推荐线路又没有讲解,本来方向感就差,直接放弃了。

去中科院植物园大巴车的预订和在曼听公园对于讲解的需求,是本次西双版纳出行对“一部手机游云南”有最直接需求的两次,但是平台没有大巴车的预订入口,讲解的体验感也没有很好,导致直接放弃了。此后的行程,尽管很多次看到平台的引导物料,但是用不到这个平台,也就没有再触达了。

总结

结合笔者这几次的出行和身边朋友的出游经验,其实游客游前的需求基本上还是市场行为的OTA、生活服务平台和UGC来满足,作为业内人的笔者都很少使用当地的“一X游”平台,更别提非业内的游客了。游中的过程其实**是有需求的,尤其是公共服务类和体验类的需求,但是这部分的需求尚不能得到很好的满足。游后**除非有非常不好的体验,一般不会有投诉上的需求,特产的购买也很少会在目的地平台上进行,主要还是在电商平台上。

游前使用少的情况一方面是OTA、生活服务平台和UGC平台已经占据了游客心智,游客们在出游时首先想到的就是这些平台,而不是其他,且大交通目的地平台基本上都提供不了,这个入口引流很重要。另一方面是游客触达有限,尤其是长途出行的游客,他们很少会知道目的地的平台,也就无从说起使用了,而达到目的地后没有强刺激或者需求,他们也不会主动去使用这些平台。

“一X游”平台大多是在政府或者具有旅游职能的国企主导下建设的,他们的初衷是希望能够整合目的地的资源,打造本地化的OTA,并满足公共服务的职能要求。但现实是骨感的,游前部分无论是游客消费心智的占领,还是产品的多样化,“一X游”平台都比不过OTA和生活服务平台。平台的运营方的属性决定了,他们没有资源也没有能力真正像OTA和生活服务平台那样进行地推式地业务拓展,丰富自身产品,他们只能在可控的资源范围内实现整合。游中部分除非是把各景区自身平台取缔或者做整合,不然使用的概率也很小。

因此,“一X游”平台的定位包括目标人群的定位从一开始偏离了,作为政府或者是国企主导建设的,在基础电商功能很难突破的情况下,应该转移重点,【观点】以周边短途游为目标,并从公共服务和体验服务上着手,目标人群也应该以目的地居民为主,关注游中服务体验更合适

首先:长途游客获客成本太高,且消费频次有限,而本地居民不同,他们对本地化的需求是高频次的,高品质的周末游或短途游产品对他们是有吸引力的,且本地居民的触达相对容易

其次,公共服务本身就是政府职能的部分,且公共服务不等于零消费服务,景区之间的交通服务整合和完善也是可以带来收益的。

再次,真正从游客的角度提供有使用价值的体验服务是能够刺激本地居民多次使用目的地平台,提高平台用户的活跃度的,如从游客参观游览角度设计导游导览功能,提供现场导游拼团的功能。

最后,有政府公信力背书,发挥监管职能,对本地居民是有说服力的。


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



ChatGPT的工作原理

这是一篇翻译稿(使用ChatGPT翻译)

英文原文:What Is ChatGPT Doing … and Why Does It Work?

文章非常长2.8万字,文末有该文章的电子书下载,可导入微信读书进行听书

一个词一个词的输出

ChatGPT能够自动生成类似于人类书写的文本,这是非常了不起和出乎意料的。但它是如何做到的?为什么会有效果呢?我的目的在于大致概述ChatGPT内部发生了什么,然后探讨它为什么能够很好地生成我们认为有意义的文本。我应该在开始时说一下,我将专注于正在进行的大局,并且虽然我会提及一些工程细节,但我不会深入研究它们。(而且我所说的要点同样适用于其他当前“大型语言模型”[LLMs]以及ChatGPT。)
首先需要解释的是,ChatGPT 始终基本上试图产生一个“合理的延续”,无论它已经得到了什么样的文本,所谓“合理”,我们指的是“在看过数十亿个网页等内容后,人们可能会写出什么”。
假设我们有一段文本:“AI最好的地方在于它的能力”。想象一下扫描数十亿页人类书写的文本(比如网络和数字化图书)并找到所有这个文本出现的实例,然后看接下来出现什么单词以及占比是多少。ChatGPT 实际上做了类似的事情,只不过(我将解释)它不是查看字面文字;而是寻找某种意义上“匹配”的东西。但最终结果是生成一个可能跟随该文本的单词排名列表,并带有“概率”:

而令人惊奇的是,当ChatGPT做一些事情,比如写一篇文章时,它实际上只是一遍又一遍地问自己:“在已有的文本基础上,下一个词应该是什么?”-每次都添加一个单词。(更准确地说,我将解释为什么它会添加“标记”,这可能只是单词的一部分,这就是为什么它有时可以“创造新单词”的原因。)
但是,每一步它都会得到一个带有概率的单词列表。但是它应该选择哪个单词添加到文章(或其他正在编写的内容)中呢?人们可能认为应该选择“排名最高”的单词(即分配了最高“概率”的单词)。但这就是巫术开始渗入的地方。因为出于某种原因——也许有一天我们会对此有科学式的理解——如果我们总是选择排名最高的单词,我们通常会得到一个非常“平淡无奇”的文章,似乎从未表现出任何创造力(甚至有时逐字重复)。但如果偶尔(随机地)选择较低排名的单词,则可以获得更加“有趣”的文章。
这里的随机性意味着,如果我们多次使用相同的提示,每次得到的文章很可能会不同。并且,符合巫术思想的是,有一个特定的所谓“温度”参数来确定使用低排名词汇的频率,在生成文章方面,“温度”为0.8似乎最好。(值得强调的是这里没有使用任何“理论”,只是根据实践发现什么有效而已。例如,“温度”的概念之所以存在是因为指数分布在统计物理学中很常见,但至少就目前而言,并没有与物理学建立起联系。)
在我们继续之前,我应该解释一下,为了说明的目的,我大多数情况下不会使用ChatGPT中完整的系统;相反,我通常会使用一个更简单的GPT-2系统,它有一个很好的特点:它足够小,在标准桌面计算机上运行。因此,对于我展示的所有内容,我都能够包含明确的Wolfram语言代码,并且您可以立即在您自己电脑上运行。(点击这里任何图片以复制其后面的代码。)

例如,这是如何获取上面的概率表格。首先,我们必须检索基础的“语言模型”神经网络:

稍后,我们将深入研究这个神经网络,并讨论它的工作原理。但现在,我们可以把这个“网络模型”视为黑盒子应用于到目前为止的文本中,并询问模型所说的应该跟随的概率最高的前5个单词:


这将获取该结果并将其转换为显式格式的“数据集”:


如果一个人反复“应用模型”,在每一步中添加具有最高概率的单词(在此代码中指定为模型的“决策”),则会发生以下情况:

如果继续下去会发生什么?在这种“零温度”的情况下,很快就会变得混乱和重复。


但是,如果我们不总是选择“顶部”单词,而是有时随机选择“非顶部”单词(其中的“随机性”对应于温度0.8),会怎样呢?同样可以构建文本:”。

每次这样做时,都会进行不同的随机选择,文本也会因此而不同——就像这5个例子一样:

值得指出的是,即使在第一步,也有很多可能的“下一个单词”可供选择(在温度0.8时),尽管它们的概率很快就会下降(是的,在这个对数-对数图上的直线对应于n-1“幂律”衰减,这非常符合语言的一般统计规律):

那么如果一个人继续下去会发生什么?这是一个随机的例子。它比零温度情况更好,但最多也只是有点奇怪。

这是使用最简单的GPT-2模型(来自2019年)完成的。使用更新和更大的GPT-3模型,结果会更好。以下是使用相同“提示”生成的零温度文本中排名第一的词语,但使用了最大的GPT-3模型:

这里是一个在“温度0.8”下的随机示例:

概率从哪里来?

好的,ChatGPT总是根据概率选择下一个单词。但这些概率从哪里来?让我们先考虑一个更简单的问题。我们考虑逐个字母(而不是单词)生成英文文本。我们如何计算每个字母的概率呢?

我们可以做的非常简单的事情是只需取一段英文文本样本,然后计算其中不同字母出现的频率。例如,这个例子统计了维基百科上“猫”条目中字母的数量:

这也同样适用于“狗”:


结果是相似的,但并不完全一样(“o”在“dogs”文章中无疑更常见,因为毕竟它出现在单词“dog”本身中)。然而,如果我们采集足够大量的英文文本样本,最终可以期望得到至少相当一致的结果:

这是一个样例,如果我们只使用这些概率生成一系列字母,就会得到以下结果:

我们可以通过将空格视为带有一定概率的字母来将其分解为“单词”:

我们可以通过强制“单词长度”的分布与英语中的相符来稍微更好地生成“单词”:

我们这里没有得到任何“实际单词”,但结果看起来稍微好一些了。不过,要进一步发展,我们需要做的不仅仅是随机地挑选每个字母。例如,我们知道如果有一个“q”,下一个字母基本上必须是“u”。

以下是各个字母概率的图表:

这里是一个显示典型英文文本中字母对(“2-gram”)概率的图表。可能的第一个字母显示在页面上方,第二个字母则显示在页面下方:

我们在这里看到,例如,“q”列除了“u”行以外都是空白(零概率)。好的,现在不再一次生成我们的“单词”,而是使用这些“2-gram”概率一次生成两个字母。以下是结果样本——其中包括一些“实际单词”:”。

有足够多的英文文本,我们不仅可以得到单个字母或字母对(2-gram)的概率估计,还可以得到更长一段文字的概率估计。如果我们使用逐渐变长的n-gram概率生成“随机单词”,我们会发现它们逐渐变得“更加真实”。

但是现在让我们假设——就像ChatGPT一样——我们正在处理整个单词,而不是字母。英语中有大约40,000个常用单词。通过查看大量的英文文本(比如几百万本书,总共数百亿个单词),我们可以估计每个单词的使用频率。并且利用这些信息,我们可以开始生成“句子”,其中每个单词都是独立随机选择的,并具有它在语料库中出现的相同概率。以下是一个示例:

毫不奇怪,这是无意义的。那么我们该如何做得更好呢?就像处理字母一样,我们可以开始考虑不仅单个词的概率,还要考虑成对或更长的n元组词语的概率。针对成对情况进行此操作时,以下是5个例子,在所有情况下都从“猫”这个词开始:

它看起来变得稍微更加“合理”。我们可以想象,如果我们能够使用足够长的n-gram,基本上就会“获得一个ChatGPT”——也就是说,我们将获得一些生成具有“正确整体文章概率”的长度为论文的单词序列。但问题在于:甚至没有足够的英语文本被写出来以便推断这些概率。

在网络爬虫中可能有数千亿个单词;在已数字化的书籍中可能还有另外数百亿个单词。但是,即使使用40,000个常用单词,所有可能的二元组合也已经达到了16亿之多——而所有可能的三元组合则高达60万亿。因此,我们无法从现有文本中估计出这些概率。当我们考虑包含20个单词的“文章片段”时,其可能性数量已经超过了宇宙中粒子的数量,因此从某种意义上说,它们永远无法全部写下来。

那我们能做什么呢?大的想法是建立一个模型,让我们能够估计序列出现的概率——即使我们在查看的文本语料库中从未明确地看到过这些序列。而 ChatGPT 的核心正是所谓的“大型语言模型”(LLM),它被构建成能够很好地估计这些概率。

什么是模型?

假设你想知道(就像加利略在16世纪末那样)从比萨斜塔的每层楼扔下炮弹需要多长时间才能撞到地面。好吧,你可以在每种情况下进行测量并制作结果表。或者你可以做理论科学的本质:制作一个模型,给出一些计算答案的过程,而不仅仅是测量和记忆每个案例。

让我们想象我们有(有点理想化的)数据,了解炮弹从各个楼层掉落需要多长时间:

我们如何计算从我们没有明确数据的楼层掉落需要多长时间?在这种情况下,我们可以使用已知的物理定律来计算。但是假设我们只有数据,而不知道支配它的基本规律,则可能会进行数学猜测,例如也许应该将直线作为模型:

我们可以选择不同的直线。但这条直线平均上最接近我们所给出的数据。从这条直线,我们可以估计任何楼层的落下时间。

我们怎么知道要在这里尝试使用一条直线?在某种程度上,我们并不知道。这只是数学上简单的事情,而且我们习惯于许多测量到的数据都能够很好地适应数学上简单的东西。 我们可以尝试更复杂的数学方法 - 比如 a + b x + c x2 - 在这种情况下,效果会更好:

事情可能会变得非常糟糕。比如,这是我们在 a + b/x + c sin(x) 上能做到的最好结果:

值得理解的是,从来没有“无模型模型”。您使用的任何模型都具有某些特定的基础结构-然后是一组“可以转动的旋钮”(即可以设置的参数),以适应您的数据。在ChatGPT的情况下,使用了许多这样的“旋钮”-实际上有1750亿个。

但值得注意的是,ChatGPT 的基础结构 - 仅仅拥有如此之多数量级参数 - 足以制作一个计算下一个单词概率足够好以给我们合理长度文本段落。

类人任务模型

我们上面提到的例子涉及制作数值数据模型,这些数据基本上来自简单物理学——几个世纪以来我们已经知道“简单的数学适用”。但是对于ChatGPT,我们必须制作一个人类大脑产生的语言文本模型。而对于这样的东西,我们(至少目前)没有像“简单数学”那样的任何东西。那么它可能是什么样子呢?

在谈论语言之前,让我们谈论另一项类似人类任务:识别图像。作为其中一个经典机器学习示例,让我们考虑数字图像(是的)。

我们可以做的一件事是为每个数字收集一堆样本图片:

然后,为了确定我们输入的图像是否对应于特定的数字,我们可以使用样本进行逐像素比较。但是作为人类,我们似乎做得更好——因为即使数字是手写的,并且具有各种修改和扭曲,我们仍然能够识别它们:

当我们为上面的数字数据建立模型时,我们能够取得给定的数值x,并计算特定a和b的a + bx。因此,如果我们将每个像素的灰度值视为某些变量xi,则是否存在一些所有这些变量的函数可以告诉我们图像所代表的数字?事实证明是可能构造出这样一个函数。但并不奇怪,它并不特别简单。一个典型例子可能涉及大约50万次数学运算。

但最终结果是,如果我们将图像中每个像素点的集合馈送到该函数中,则会输出指定该数字对应于哪个手写数字图像的编号。稍后,我们将讨论如何构建这样一个函数以及神经网络的概念。但现在让我们把这个功能当作黑盒子来处理,在那里我们输入手写数字(作为像素值数组)并获得相应数字:

但是这里真正发生了什么?假设我们逐渐模糊一个数字。一段时间内,我们的函数仍然“识别”它,例如作为“2”。但很快它就会“失去”,并开始给出“错误”的结果:

但是为什么我们说这是“错误”的结果呢?在这种情况下,我们知道通过模糊“2”来得到了所有的图像。但如果我们的目标是产生一个人类识别图像的模型,真正需要问的问题是:如果向人类展示其中一张模糊的图片,并不知道它来自哪里,那么人类会怎样做?

如果我们从函数中获得的结果通常与人类所说的相符,则说明我们有一个“好模型”。而非平凡科学事实是,在像这样的图像识别任务中,我们现在基本上已经知道如何构建能够完成此任务的函数。

那么,我们能否“数学证明”它们有效呢?嗯,并不能。因为要做到这一点,就必须对人类正在进行什么样的数学理论进行解释。拿出“2”号图像并更改几个像素。也许只需少量变化即可将其视为“2”。但应该持续多久呢?这涉及到人类视觉感知问题。而且,确实可以肯定地说对于蜜蜂或章鱼等动物答案可能会有所不同——对于外星生命体则完全不同。

神经网络

神经网络 好的,那么我们通常用于图像识别等任务的模型是如何工作的呢?目前最流行和成功的方法使用神经网络。神经网络在1940年代被发明出来,其形式与今天使用的形式非常接近,可以被认为是大脑运作方式简化后的理想化模型。

人类大脑中有约1000亿个神经元(神经细胞),每个神经元能够产生电脉冲高达一千次/秒。这些神经元以复杂网状连接在一起,每个神经元都有树枝状分支,使其能够向数千个其他神经元传递电信号。粗略地说,在任何给定时刻一个特定的神经元是否会产生电脉冲取决于它从其他不同连接收到了哪些“权重”不同的信号。

当我们“看到一张图片”时,光子从图像落在眼睛后部(“光感受器”)细胞上时会在神经细胞中产生电信号。这些神经细胞与其他神经细胞相连,并最终通过整个序列层级结构传递信号。正是通过这种过程我们才能够“识别”图像,最终“形成思想”,认为我们正在“看到一个2”(也许最后会做出类似于大声说出“二”的动作)。

前一节中的“黑盒子函数”是这种神经网络的数学化版本。它恰好有11层(尽管只有4个“核心层”)。

这个神经网络并没有什么特别的“理论推导”,它只是在1998年作为一种工程构建出来,并被发现可以工作。当然,这与我们描述大脑是通过生物进化过程产生的方式并没有太大不同。

好了,但是这样的神经网络如何“识别事物”呢?关键在于吸引子的概念。想象一下,我们有手写的数字1和2:

我们希望所有的1都“被吸引到一个地方”,所有的2都“被吸引到另一个地方”。或者换一种说法,如果一幅图像在某种程度上比成为2更接近于成为1,我们希望它最终出现在“1位置”,反之亦然。

作为一个简单的类比,假设我们有平面上的某些位置,用点表示(在实际生活中可能是咖啡店的位置)。那么我们可以想象从平面上任何一点开始,我们总是想要最终到达最近的点(即我们总是去最近的咖啡店)。我们可以通过将平面分割成由理想化“分水岭”隔开的区域(“吸引子盆地”)来表示这一点:

我们可以将其视为实现一种“识别任务”,其中我们不是在识别给定图像“最像哪个数字”,而是直接看到给定点最接近的点。 (我们在这里展示的“ Voronoi 图”设置将 2D 欧几里得空间中的点分开; 数字识别任务可以被认为是做类似的事情 - 但在由每个图像中所有像素的灰度级形成的 784 维空间中进行。)
那么我们如何让神经网络“执行识别任务”呢?让我们考虑这个非常简单的情况:

我们的目标是将与位置{x,y}相对应的“输入” - 然后将其识别为最接近它的三个点之一。换句话说,我们希望神经网络计算像{ x,y }这样的函数:

那么我们如何使用神经网络来完成这个任务呢?最终,神经网络是由理想化的“神经元”组成的连接集合——通常排列在不同层中。一个简单的例子如下:

每个“神经元”都被有效地设置为评估一个简单的数值函数。要使用这个网络,我们只需在顶部输入数字(如我们的坐标x和y),然后让每层神经元“评估它们的函数”,并将结果向前传递到网络中 - 最终在底部产生最终结果:

在传统的(生物启发式)设置中,每个神经元实际上都有一定数量的“输入连接”,这些连接来自前一层的神经元,每个连接被分配一个特定的“权重”(可以是正数或负数)。给定神经元的值由将“先前神经元”的值乘以它们对应的权重,然后加上一个常量并最终应用“阈值化”(或“激活”)函数来确定。在数学术语中,如果一个神经元具有输入x = {x1、x2…},则我们计算f [w.x + b],其中权重w和常量b通常为网络中每个神经元选择不同;函数f通常相同。

计算w . x + b只是矩阵乘法和加法问题。“激活函数”f引入了非线性(最终导致非平凡行为)。各种激活功能通常会得到使用; 在这里我们将仅使用Ramp(或ReLU):

对于我们希望神经网络执行的每个任务(或等效地,对于我们希望它评估的每个总体功能),我们将有不同的权重选择。(正如我们稍后将讨论的那样,这些权重通常是通过使用机器学习从输出示例中“训练”神经网络来确定的。)

最终,每个神经网络只对应某种整体数学函数 - 尽管可能很难写出来。 对于上面的示例,它将是:

ChatGPT的神经网络也只是对应于一个像这样的数学函数——但实际上有数十亿个术语。 但让我们回到单个神经元。以下是一些具有两个输入(表示坐标x和y)的神经元可以使用各种权重和常量计算的功能示例(并且Ramp作为激活函数):

那么,上面的更大网络呢?它计算出了什么:

它不完全是“正确的”,但它接近我们上面展示的“最近点”函数。 让我们看看其他一些神经网络会发生什么。在每种情况下,正如我们稍后将解释的那样,我们使用机器学习来找到最佳权重选择。然后,在这里展示具有这些权重的神经网络计算出了什么:

更大的神经网络通常更能够逼近我们所追求的函数。在每个吸引子盆地的中心,我们通常可以得到确切的答案。但是在边界处——当神经网络“难以做出决定”时——情况可能会变得混乱。

通过这个简单的数学式“识别任务”,很明显可以知道什么是“正确答案”。但在手写数字识别问题中,情况就不那么清晰了。如果有人把一个“2”写得像一个“7”等等,怎么办?尽管如此,我们仍然可以问神经网络如何区分数字——这给出了一些指示:

我们能用“数学方法”来描述网络是如何进行区分吗?实际上不行。它只是在“做神经网络所做的事情”。但事实证明,这通常与我们人类所做出的区分相当一致。 让我们举一个更复杂的例子。比方说,我们有猫和狗的图像,并且有一个已经被训练好了以区分它们的神经网络。以下是它在某些示例中可能会执行的操作:

现在,“正确答案”变得更加不清楚了。比如,一只穿着猫装的狗会怎样呢?无论输入什么,神经网络都会生成一个答案。而且,事实证明它以一种与人类相当一致的方式进行操作。正如我上面所说的那样,这并不是我们可以“从第一原理中推导出来”的事实。这只是某些领域内已被发现为真实的东西。但这也是神经网络有用的关键原因:它们以某种方式捕捉到了“类似于人类”的做事方式。

向自己展示一张猫的图片,并问:“为什么那是只猫?”你可能会开始说:“嗯,我看到了它尖耳朵等等。”但很难解释你如何认出该图像为猫。就好像你大脑里想通了似的。但对于大脑来说(至少目前还没有),没有办法“进入其中”并查看其运作过程。那么对于(人工)神经网络呢?当您展示给它一张猫图片时,每个“神经元”都在做什么非常容易看出来。但即使要得到基本可视化通常也非常困难。

在我们用于上述“最近点”问题的最终网络中,有17个神经元。在识别手写数字的网络中有2190个神经元。而在我们用来识别猫和狗的网络中则有60,650个神经元。通常很难可视化相当于60,650维空间的东西。但由于这是一个处理图像的网络,它许多层次的神经元都被组织成了数组,就像它正在查看像素数组一样。

如果我们拿一张典型的猫图片作为例子……
Cat

然后,我们可以通过一系列派生图像来表示第一层神经元的状态——其中许多我们可以轻松解释为“没有背景的猫”或“猫的轮廓”。

但总的来说,我们可以说神经网络正在“挑选某些特征”(也许尖耳朵是其中之一),并利用这些特征来确定图像的内容。但这些特征是否具有名称,比如“尖耳朵”?大多数情况下不是。

我们的大脑是否使用类似的特征?大多数时候我们不知道。但值得注意的是,像我们在这里展示的神经网络的前几层似乎会挑选出与人类视觉处理第一层所选择相似(例如物体边缘) 的图像方面。

但假设我们想要一个关于神经网络中猫识别理论。 我们可以说:“看,这个特定网络就能做到”,并立即给我们一些关于“问题难度”的概念(例如需要多少个神经元或层数)。 但至少目前为止,我们没有办法“提供叙述性描述”该网络正在执行什么操作。也许这是因为它真正地计算上不可约简,并且除了明确跟踪每个步骤外,没有通用方法找到它所做的事情。 或者可能只是因为我们还没有“弄清楚科学”,并确定了允许我们总结发生了什么事情 的 “自然规律”。

当我们谈论使用ChatGPT生成语言时,我们将遇到相同类型的问题。同样不清楚是否有方法“总结它正在做什么”。但是语言的丰富性和细节(以及我们对其的经验)可能使我们比图像更进一步。

机器学习和神经网络的训练

到目前为止,我们一直在谈论“已经知道”如何执行特定任务的神经网络。但是,使神经网络如此有用(也应该适用于大脑)的原因不仅在于它们原则上可以执行各种任务,而且还可以通过逐步“从示例中进行训练”来完成这些任务。

当我们制作一个区分猫和狗的神经网络时,我们实际上不必编写一个程序(比如明确查找触须);相反,我们只需展示很多关于什么是猫和什么是狗的例子,并让网络从中“机器学习”,以了解如何区分它们。

而且重要的是,受过培训的网络会从所显示出来的具体示例中“概括”。正如我们之前看到过得那样,并不仅仅是因为该网络识别了其所显示出来图像中特定像素模式上面部分属于哪个类别;相反地,在某种程度上说,就算基础数据发生变化或者噪声干扰等情况下, 神经元能够根据一些被认为具有普遍性质并将其与其他物品区分开来。

那么神经网络的训练实际上是如何工作的呢?基本上,我们一直在尝试找到使神经网络成功复制所给出示例的权重。然后,我们依靠神经网络以“合理”的方式进行“插值”(或“概括”)这些示例之间。

让我们看一个比上面最近点问题更简单的问题。让我们只是尝试让神经网络学习函数:

对于这个任务,我们需要一个只有一个输入和一个输出的网络,例如:

但是我们应该使用什么权重等呢?对于每组可能的权重,神经网络都会计算出某个函数。例如,这里是它在几组随机选择的权重下所做的事情:

是的,我们可以清楚地看到,在这些情况下,它甚至都无法接近我们想要的功能。那么如何找到能够复制该函数的权重?

基本思路是提供大量“输入→输出”示例以供“学习”,然后尝试找到能够复制这些示例的权重。以下是使用逐渐增加的示例进行操作所得出的结果:

在这个“训练”过程的每个阶段,网络中的权重都会逐步调整——我们看到最终得到了一个成功复现所需函数的网络。那么我们如何调整权重呢?基本思想是在每个阶段看一下离目标函数还有多远,然后以更接近目标为方式更新权重。

为了找出“离目标还有多远”,我们计算通常称为“损失函数”(或者有时称之为“代价函数”)的东西。在这里,我们使用简单(L2)损失函数,它只是差值平方和与真实值之间差异的总和。随着训练过程不断进行,我们发现损失函数逐渐减小(遵循不同任务不同的特定“学习曲线”),直到达到一个点,在该点上网络至少可以很好地近似复制所需功能:

好的,最后一个需要解释的关键部分是如何调整权重以减少损失函数。正如我们所说,损失函数给出了我们得到的值与真实值之间的“距离”。但是,“我们得到的值”在每个阶段都由当前版本的神经网络和其中的权重确定。现在想象一下,这些权重是变量——比如wi。我们想找出如何调整这些变量的值来最小化依赖于它们的损失。 例如,在对实际使用中典型神经网络进行极度简化时,假设只有两个权重w1和w2。那么我们可能会有一个损失函数作为w1和w2 的函数看起来像这样:

数值分析提供了各种技术来找到这种情况下的最小值。但是,一个典型的方法就是从我们之前拥有的任何w1、w2开始逐步跟随最陡峭的路径:

就像水流下山一样,唯一保证的是这个过程最终会到达表面的某个局部极小值(“山湖”);它很可能不会达到最终的全局极小值。

找到“权重景观”上最陡峭下降路径似乎并不明显可行。但微积分来拯救了我们。正如我们上面提到的,人们总是可以将神经网络视为计算一个数学函数——该函数取决于其输入和权重。但现在考虑对这些权重进行微分。结果发现,微积分中的链式法则实际上让我们能够“展开”神经网络中连续层所做操作。而结果是,在某种本地逼近意义下,我们可以“反转”神经网络操作,并逐步找到使输出损失最小化的权重。

上图显示了在仅有2个权重时可能需要执行的最小化类型情况(非常简单)。但事实证明即使使用更多权重(ChatGPT使用1750亿),仍然可以进行最小化处理,至少在某种逼近级别上是可能的。事实上,“深度学习”的巨大突破发生在2011年左右,与此相关联:人们发现,在涉及许多权重时(至少近似)最小化可能更容易,而在涉及相当少的权重时则更难。

换句话说——有些违反直觉——使用神经网络解决更复杂的问题可能会更容易。这个粗略的原因似乎是,当一个人拥有许多“权重变量”时,就会有一个高维空间,“很多不同方向”可以引导一个人到达极小值处——而对于较少的变量,则更容易陷入局部极小值(“山湖”),从中没有“出路”。

值得指出,在典型情况下,存在许多不同的权重集合,它们都将给出几乎具有相同性能的神经网络。通常,在实际神经网络培训中进行了大量随机选择——导致产生“不同但等效”的解决方案,如以下所示:

但是每个“不同的解决方案”都会有至少稍微不同的行为。如果我们要求在我们给出训练示例之外进行“外推”,我们可能会得到截然不同的结果:

但这些哪一个是“正确”的呢?实际上没有办法说。它们都“与观察到的数据一致”。但它们都对应着不同的“内在”方式来思考如何“跳出框架”。有些可能对我们人类来说似乎比其他更为合理。

神经网络训练的实践和技巧

特别是在过去十年中,神经网络训练的艺术已经有了很多进展。而且,它基本上就是一门艺术。有时候——尤其是事后——人们可以看到至少有一个“科学解释”来解释正在进行的某些事情。但大多数情况下都是通过试错发现的,添加想法和技巧逐渐建立了关于如何处理神经网络的重要传说。

其中有几个关键部分。首先,对于特定任务应该使用什么样的神经网络结构这个问题非常重要。然后就是如何获取用于训练神经网络的数据这个关键问题。越来越多地不再需要从头开始训练一个新网:相反,新网可以直接包含另一个已被训练好的网,或者至少可以使用那个网为自己生成更多的训练示例。

人们可能认为每种特定类型任务都需要不同架构设计的神经网络才能完成。但所发现的是,在表面上看起来完全不同任务中甚至相同架构似乎也能够工作得很好。在某种程度上这使人想起通用计算(以及我的计算等价原理)的想法,但正如我稍后将讨论的那样,我认为这更多地反映了我们通常试图让神经网络完成“类似人类”的任务——而神经网络可以捕捉相当普遍的“类人过程”。

在神经网络的早期,人们倾向于认为应该“尽可能让神经网络做得少”。例如,在将语音转换为文本时,人们认为应该先分析语音,将其分解成音素等。但是发现,在至少“类似于人类任务”的情况下,最好只是尝试在“端到端问题”上训练神经网络,并让它自己“发现”必要的中间特征、编码等。

还有一个想法是应该向神经网络引入复杂的单个组件,以使其实际上“明确地实现特定算法思想”。但同样地,这大多数情况下都不值得;相反,最好只处理非常简单的组件,并让它们(虽然通常以我们无法理解的方式)自行“组织”,从而达到(可能)与那些算法思想等效的结果。

这并不意味着没有适用于神经网络的“结构性思想”。例如,在图像处理的早期阶段使用具有局部连接的2D神经元数组似乎非常有用。并且具有集中于“回顾序列”的连接模式似乎很有用——正如我们稍后将看到——在处理诸如人类语言之类的事物时, 例如在ChatGPT中。

但神经网络的一个重要特征是,就像计算机一样,它们最终只处理数据。而当前的神经网络——使用当前的神经网络训练方法——具体处理数字数组。但在处理过程中,这些数组可以完全重新排列和重塑。例如,在上面用于识别数字的网络从2D“类似图像”的数组开始,迅速“加厚”到多个通道,然后“集中”成一个1D数组,最终包含表示不同可能输出数字的元素:

但是,好吧,如何确定特定任务所需的神经网络大小呢?这有点像一门艺术。在某种程度上,关键是要知道“任务有多难”。但对于类似人类的任务来说,通常很难估计。是的,可能有一种系统化的方法可以通过计算机非常“机械地”完成任务。但很难知道是否存在可以让人以至少“与人类水平”轻松完成任务的技巧或捷径。可能需要枚举一个巨大的游戏树才能“机械地”玩某个游戏;但可能会有更简单(“启发式”的)方法来实现“人类级别”的游戏。

当处理微小神经网络和简单任务时,有时候可以明确看到自己无法从此处开始解决问题。例如,在前面章节中使用几个小型神经网络进行该任务时最好做到了什么。

我们看到的是,如果神经网络太小,它就无法复制我们想要的功能。但是在某个大小以上,只要训练足够长时间并提供足够多的示例,它就没有问题了。顺便说一下,这些图片说明了神经网络传说中的一个部分:如果中间有一个“挤压”,强制所有东西都通过较少数量的中间神经元,则通常可以使用更小的网络。(值得一提的是,“无中间层”或所谓“感知器”网络只能学习基本上线性函数——但只要有一个中间层即可原则上任意逼近任何函数,并且至少具备足够多神经元时总是可能实现这一点;不过为了使其可行地进行训练,通常需要某种形式的正则化或归一化。)

好吧,假设我们已确定了某种神经网络架构。现在存在获取用于训练该网络数据方面的问题。对于神经网路和机器学习等领域很多实际挑战都集中在获取或准备必要训练数据方面。(在很多情况下(“监督学习”),人们希望获得输入和期望输出之间明确示例)。例如,在图像分类任务中,人们可能希望对图像进行标记。也许需要明确地经过大量努力进行标记。但是很多时候,事实证明可以利用已有的东西或将其用作某种代理。例如,在网络上提供了图片的alt标签;在不同领域中,人们可能会使用为视频创建的闭式字幕;或者在语言翻译培训方面,可以使用存在于不同语言中的网页或其他文档的平行版本。

需要多少数据来展示神经网络以训练它完成特定任务?从第一原理出发很难估计。当然,通过使用“迁移学习”将已在另一个网络中学习的重要特征列表“转移”,可以大大减少要求。但通常情况下,神经网络需要“看到很多例子”才能进行良好的训练。对于某些任务而言,至少有一个关键点是例子可能会非常重复。实际上,只需向神经网络显示所有现有的例子即可成为标准策略,在每个这样的“训练轮次”(或“时代”)中,神经网络都将处于至少稍微不同的状态,并且以某种方式提醒它记住特定示例对于使其记住该示例是有用的。(是的,“也许这类似于人类记忆中重复性所具有的用处。”)

但仅仅反复显示相同示例并不足够。还需要向神经网络展示该示例变化版本。而且作为神经网路传说中一项功能,“数据增强”的变化并不必须精密才能派上用场。只需使用基本图像处理轻微修改图像即可使其在神经网路培训方面与新的图像一样好。同样,当用于训练自动驾驶汽车的实际视频等数据已经耗尽时,可以继续在模型类似于电子游戏环境中运行的仿真中获取数据,而不需要所有实际现实场景的详细信息。

那么ChatGPT之类的东西呢?嗯,它有一个很好的特点,即它可以进行“无监督学习”,这使得从中获得示例以进行培训变得更加容易。回想一下ChatGPT的基本任务是找出如何延续给定文本片段。因此,要获得“培训示例”,只需获取一段文本,并屏蔽其末尾部分,然后将其用作“输入来进行培训”——输出为完整未屏蔽文本片段。我们稍后会进一步讨论这个问题,但主要观点是与例如学习图像内容相比,“没有明确标记”的必要性; ChatGPT 实际上可以直接从任何给定文本示例中学习。

好的,那么神经网络中的实际学习过程怎样呢?最终目标是确定哪些权重能够最好地捕捉到所给出的训练示例。有各种详细选择和“超参数设置”(因为权重可以被视为“参数”)可用于调整如何完成此操作。有不同选择的损失函数(平方和、绝对值之和等)。有不同的损失最小化方法(每步在权重空间移动多远等)。然后还有像展示多少个“批次”的示例来获得每个连续估计要尽量减小的损失这样一类问题。是的,我们可以应用机器学习(例如 Wolfram 语言中所做的)来自动化机器学习,并自动设置诸如超参数之类的东西。

但归根结底,整个训练过程可以通过观察损失逐渐减少来描述(就像这个 Wolfram 语言进度监视器显示了一个小型训练)。

通常情况下,我们会看到损失函数在一段时间内逐渐减小,但最终会趋于某个固定值。如果这个值足够小,则可以认为训练成功;否则就意味着需要尝试改变网络结构。

有人能说出“学习曲线”要平稳需要多长时间吗?就像许多其他事情一样,似乎存在大致的幂律缩放关系,这取决于使用的神经网络大小和数据量。但总体结论是训练神经网络很难,并且需要大量计算工作。实际上,其中绝大部分工作都花费在对数字数组进行操作上,而这正是GPU擅长的领域——这也是为什么神经网络训练通常受到GPU可用性限制的原因。

在未来,训练神经网络或者说完成神经网络所做的事情是否会有根本性的改进?我认为几乎肯定会。神经网络的基本思想是利用大量简单(基本上相同)组件创建一个灵活的“计算结构”,并使这个“结构”能够通过逐步修改从示例中学习。在当前的神经网络中,人们基本上使用微积分理论——应用于实数——来进行逐步修改。但越来越清楚地是,高精度数字并不重要;即使使用当前方法,8位或更少可能已经足够了。

使用计算系统,如元胞自动机,在许多个体位于并行操作的情况下,如何进行这种增量修改一直不太清楚,但没有理由认为这是不可能的。实际上,就像“2012年深度学习突破”一样,在更复杂的情况下进行这种增量修改可能会更容易。

神经网络——也许有点像大脑——被设置为具有基本固定的神经元网络,并且所修改的是它们之间连接的强度(“权重”)。 (也许在至少年轻人脑中可以显着增长全新连接数。)但是虽然这对生物来说可能是一个方便的设置,但它根本不清楚是否接近我们需要实现功能性所需最佳方法。而涉及等效于渐进式网络重写(也许类似于我们物理项目)的某些东西最终可能会更好。

但是,即使在现有神经网络的框架内,目前仍存在一个关键限制:神经网络训练现在基本上是顺序进行的,每个示例批次的影响被传播回来更新权重。实际上,在当前计算机硬件(即使考虑到GPU)中,大部分神经网络在训练期间都处于“空闲”状态,只有一部分正在更新。从某种意义上说,这是因为我们当前的计算机往往具有与其CPU(或GPU)分离的存储器。但在大脑中可能不同——每个“记忆元素”(即神经元)也可以成为潜在的活动计算元素。如果我们能够以这种方式设置未来的计算机硬件,则可能会更有效地进行培训。

一个足够大的网络一定可以做任何事情

像ChatGPT这样的东西的能力似乎非常令人印象深刻,以至于人们可能会想象,如果可以“继续前进”并训练更大的神经网络,那么它们最终将能够“做任何事情”。如果一个人关心那些对立即人类思考容易获得的事情,那么这很有可能是真的。但过去几百年科学所教导我们的教训是:有些问题可以通过形式化过程解决,但不一定容易被立即理解。

非平凡数学就是一个很好的例子。但总体来说实际上是计算。最终问题在于计算不可约性现象。有些计算看起来需要多个步骤才能完成,但实际上可以“简化”为某种相当直接的东西。然而发现了计算不可约性意味着这种方法并不总是奏效。相反地存在一些过程——可能像下面这个——无论如何都需要基本追踪每个计算步骤才能弄清楚会发生什么:

我们通常用大脑做的事情,可能是有意选择避免计算不可简化的。在脑中进行数学运算需要特别努力。而且,在实践中,“思考”任何非平凡程序操作步骤只靠大脑基本上是不可能的。

但当然我们有电脑可以解决这个问题。通过电脑,我们可以轻松地完成长时间、计算不可简化的任务。关键点在于一般来说没有捷径。

是的,我们可以记忆许多某些特定计算系统中发生了什么事情的具体例子。也许我们甚至能够看到一些(“计算可简化”的)模式,从而使我们能够进行少量泛化推理。但重要的是,计算不可简化意味着我们永远无法保证出现意外情况——只有通过明确地执行计算才能确定任何特定情况下实际发生了什么。

最终,在学习和计算不可简约之间存在根本性张力。学习实质上涉及利用规律压缩数据。但是,计算不可简约意味着最终存在规律性所限制的极限。

作为实际问题,人们可以想象将小型计算设备(如元胞自动机或图灵机)构建到可训练的系统中,例如神经网络。事实上,这些设备可以作为神经网络的良好“工具”,就像 Wolfram|Alpha 可以成为 ChatGPT 的良好工具一样。但是计算不可约性意味着我们不能指望“进入”这些设备并使它们学习。

换句话说,在能力和可训练性之间存在最终权衡:您希望系统充分利用其计算能力越多,则它会显示出更多的计算不可约性,并且它将变得越来越难以训练。而基本上易于训练的系统则无法进行复杂的计算。

对于当前版本的 ChatGPT 来说,情况实际上更加极端,因为用于生成每个输出标记的神经网络是纯“前馈”网络,没有循环结构,因此无法执行任何具有非平凡“控制流”的计算。)

当然,人们可能会想知道是否真正重要能够进行不可约化运算。事实上,在人类历史大部分时间里都不是特别重要。但我们现代技术世界已经建立在至少使用数学计算的工程基础之上,而且越来越多地使用更一般的计算。如果我们观察自然界,它充满了不可约化运算——我们正在逐渐理解如何模拟和利用这些运算以实现技术目的。

是的,神经网络肯定可以注意到自然界中我们也可以轻松注意到的那些规律。但是,如果我们想解决数学或计算科学范畴内的问题,除非它有效地“使用”一个“普通”的计算系统作为工具,否则神经网络将无法完成这项任务。

但是所有这些可能会让人感到困惑。过去有很多任务——包括写文章——我们认为电脑在某种程度上“根本太难了”。现在我们看到像ChatGPT这样的东西完成了这些任务,就突然认为电脑一定变得更加强大了——特别是超越了它们已经基本能够做到的事情(比如逐步计算细胞自动机等计算系统的行为)。

但这不是正确的结论。计算不可约化过程仍然是计算不可约化过程,并且对于电脑来说仍然基本上很难——即使电脑可以轻松地计算它们各个步骤。相反,我们应该得出结论:像写文章之类的任务虽然人类能够做到,但我们并没有认为电脑能够做到,在某种意义上实际上比我们想象中要容易处理。

换句话说,神经网络能够成功地写一篇文章的原因是因为写作事实上比我们想象中更浅显易懂。从某种意义上讲,这使我们更接近“拥有一个理论”,即如何像人类那样处理语言或撰写文章。

如果你有足够大的神经网络,那么你可能能够做到人类可以轻松完成的任何任务。但你不会捕捉到自然界普遍具备的特性——或者说我们从自然界中创造出来的工具所能做到的事情。正是使用这些工具——无论是实用还是概念性的——在最近几个世纪里使我们超越了“纯粹依靠人类思维”的边界,并为人类目标捕获了更多物理和计算宇宙中存在着什么。

embedding的概念

神经网络——至少目前的设置是基于数字的。因此,如果我们要用它们来处理文本之类的东西,我们需要一种用数字表示文本的方法。当然,我们可以像ChatGPT一样(实质上)为字典中的每个单词分配一个数字。但有一个重要的想法——例如ChatGPT所关注的那个——超越了这一点。这就是“embedding(嵌入)”的概念。人们可以将嵌入视为通过一系列数字尝试以某种方式表达事物“本质”的方法,并且具有“附近事物”由相邻数字表示的属性。

因此,例如,我们可以将单词embedding视为在某种意义上接近于含义空间中排列单词,在该空间中,“意思相近”的单词会出现在embedding附近。实际使用的嵌入(比如ChatGPT)往往涉及大量数字列表。但是如果我们投影到2D,则可以展示单词如何被embedding布局:

是的,我们所看到的确实非常好地捕捉了典型的日常印象。但是我们如何构建这样一个embedding呢?大致上的想法是查看大量文本(这里来自网络的50亿个单词),然后观察不同单词出现在其中“环境”有多相似。因此,例如,“鳄鱼”和“鳄鱼”通常几乎可以互换使用在其他类似句子中,这意味着它们将被放置在嵌入附近。但是,“萝卜”和“老鹰”不太可能出现在其他类似句子中,因此它们将被放置在embedding远处。

但是如何使用神经网络实际实现这样的事情呢?让我们先谈论一下图像而不是单词的嵌入。我们希望以某种方式通过数字列表来表征图像,使得“我们认为相似”的图像被分配相似的数字列表。

我们如何判断是否应该“认为图像相似”?如果我们的图像是手写数字,则如果它们属于同一个数字,则可以“认为两个图像相似”。早些时候,我们讨论过一个用于识别手写数字的神经网络。 我们可以将这个神经网络看作是设置了一个最终输出,将图像放入10个不同的箱中,每个数字一个。

但是如果我们在“它是‘4’”决定之前“拦截”神经网络内部正在发生的事情呢?我们可能会预期,在神经网络内部有一些数字来表征图像为“大多数类似于4但有点2”的形式。而这个想法就是挑选出这样的数字用作嵌入中的元素。

这里是概念。与其直接尝试表征“哪个图像靠近哪个图像”,我们反而考虑一个明确定义的任务(在这种情况下是数字识别),为此我们可以获得明确的训练数据,然后利用事实,在完成这项任务时神经网络隐含地必须做出类似于“接近性决策”的决策。因此,我们不需要明确谈论“图像的接近程度”,而只需谈论关于一张图片代表什么数字的具体问题,然后将“神经网络”留给它来隐含地确定这意味着关于“图像接近程度”的内容。

那么,对于数字识别网络,这个工作的详细过程是怎样的呢?我们可以将该网络看作由11个连续层组成,我们可以用如下图标示来概括(其中激活函数显示为单独的层):

在开始时,我们将实际图像输入到第一层中,这些图像由表示为2D像素值数组的数据组成。在最后一层,我们得到一个包含10个值的数组,可以认为是网络对每个数字0到9所对应的图像“确定性”的度量。

输入图像4,最后一层神经元的值为:

换句话说,神经网络此时“非常确定”这张图片是一个4——为了得到输出的“4”,我们只需要找出具有最大值的神经元位置。

但如果我们往前看一步呢?网络中最后一个操作是所谓的softmax,它试图“强制确信”。但在应用之前,神经元的值是:

代表“4”的神经元仍然具有最高的数值。但是其他神经元的值中也包含了信息。我们可以期望这个数字列表在某种程度上可以用来描述图像的“本质”,从而提供一些可用作嵌入的东西。因此,例如,在这里每个4都有略微不同的“签名”(或“特征嵌入”)-与8完全不同:

在这里,我们基本上使用10个数字来描述我们的图像。但是通常最好使用更多的数字。例如,在我们的数字识别网络中,通过访问前一层,我们可以获得一个包含500个数字的数组。这可能是一个合理的用作“图像嵌入”的数组。

如果我们想要对手写数字进行“显式可视化”,则需要通过将我们获得的500维向量投影到三维空间中来“降低维度”。

我们刚刚讨论了如何创建图像的表征(从而嵌入),方法是通过确定它们是否(根据我们的训练集)对应于相同的手写数字来有效地识别图像的相似性。如果我们有一个标识每个图像属于哪种5000种常见物体类型(猫、狗、椅子等)的训练集,那么我们可以更普遍地为图像做同样的事情。这样,我们就可以制作一个由我们对常见物体进行识别“锚定”的图像嵌入,但是根据神经网络行为“泛化”。关键在于,在这种行为与人类感知和解释图像方式一致时,这将成为一个“看起来正确”的嵌入,并且在实践中用于执行“类似人类判断”的任务。

基于大量文本语料库(比如网络文本内容),哪些不同的单词可能填补空白?或者换句话说,“___ black ___”中不同“侧翼单词”的概率是多少?

我们如何为神经网络设置这个问题?最终,我们必须用数字来表达所有东西。一种方法就是为英语中约50,000个常见单词中的每一个分配一个唯一编号。例如,“the”可能是914,“cat”(前面有一个空格)可能是3542。(这些都是GPT-2使用的实际数字)。因此,在“the ___ cat”问题上,我们的输入可能为{914、3542}。输出应该像什么呢?好吧,它应该是一个包含大约50,000个数字列表,有效地给出了每个可能填充字眼所对应概率。再次寻找嵌入时,我们希望在神经网络“达成结论之前截取其内部”,然后获取发生在那里并且可以认为代表每个单词特征的数字列表。

好的,那这些特征是什么样子的呢?在过去的10年中,已经开发了一系列不同的系统(如word2vec、GloVe、BERT、GPT等),每个系统都基于不同的神经网络方法。但最终它们都将单词转化为由数百到数千个数字组成的列表来进行表征。

在它们的原始形式下,这些“嵌入向量”是相当无信息的。例如,以下是GPT-2为三个特定单词生成的原始嵌入向量:

如果我们像测量这些向量之间的距离一样做事情,那么我们就可以找到词语之间的“接近程度”。稍后我们将更详细地讨论这种嵌入可能具有的“认知”意义。但现在主要观点是,我们有一种有效的方法将单词转化为“神经网络友好”的数字集合。

实际上,我们不仅可以通过数字集合来描述单词,还可以对单词序列或整个文本块进行此操作。而 ChatGPT 内部正是这样处理事情的。它获取到目前为止所拥有的文本,并生成一个嵌入向量来表示它。然后其目标是找到可能出现下一个单词的不同概率。并且它将其答案表示为一系列数字列表,这些数字基本上给出了每个可能单词中大约 50,000 种可能性中各自发生概率。

(严格来说,ChatGPT 不处理单词,而是处理“标记”——方便的语言单位,可能是整个单词,也可能只是像“pre”、“ing”或“ized”这样的片段。使用标记使 ChatGPT 更容易处理罕见、复合和非英语单词,并且有时候为了好坏不一地发明新单词。)

深入ChatGPT

好的,我们终于准备讨论一下ChatGPT内部是什么了。最终,它其实就是一个巨大的神经网络——目前使用的是1750亿个权重版本的所谓GPT-3网络。在很多方面上,这个神经网络与我们之前讨论过的其他神经网络非常相似。但它特别适用于处理语言,并且它最显著的特点是一种名为“transformer”的神经网络架构。

在我们上面讨论过的第一个神经网络中,每个层次上的每个神经元基本上都与前一层次上所有其他神经元(至少有某些权重)连接起来。但如果要处理具有特定已知结构数据,则这种完全连接型网络可能会导致资源浪费。因此,在处理图像时通常会使用所谓卷积神经网(”convnets”),其中将神经元有效地布置在类似于图像中像素排列方式并仅与附近格子中其他单元相连。

Transformer 的想法则是对组成文本片段序列标记进行类似操作, 但不同之处在于 Transformer 引入了“注意力”概念和更加关注某些序列片段而非全部. 或许总有一天只需启动通用神经网络并通过训练进行所有自定义就足够了,但至少目前来看,在实践中“模块化”事物似乎是至关重要的——正如 Transformer 所做的那样,也可能与我们的大脑所做的类似。

好的,那么ChatGPT(或者更确切地说,它所基于的GPT-3网络)到底是做什么的呢?请记住,它的总体目标是根据其训练过程中看到的内容(即查看了来自Web等数十亿页文本),以“合理”的方式继续文本。因此,在任何给定时刻,它都有一定数量的文本,并且其目标是为下一个要添加的标记提供适当选择。

它分为三个基本阶段。首先,它获取与迄今为止已有文本相对应的标记序列,并找到表示这些标记序列的嵌入式数组(即一组数字)。然后,在神经网络中进行操作——通过在网络中连续层之间传播值——以生成新嵌入式数组(即新一组数字)。接着,它取该数组最后部分并从中生成大约50,000个值得出不同可能下一个标记概率的数组。(是啊,“恰好”使用了与英语常用单词数量相同数量级左右个令牌/片段作为输入数据)

关键点在于管道中每个部分都由神经网络实现,并且该神经网络通过端对端训练确定权重。换句话说,在效果上除了整体架构外,“显式工程”几乎没有什么东西;一切都是从训练数据中“学习”的。

然而,管道设置方面有很多细节——反映了各种经验和神经网络知识。尽管这肯定会变得非常复杂,但我认为谈论其中的一些细节是有用的,至少可以对构建ChatGPT所需付出的努力有一个大致了解。

首先是嵌入模块。以下是GPT-2的Wolfram语言示意图:

输入是一个由n个标记组成的向量(如前一节所述,表示为1到约50,000的整数)。每个标记都被转换为嵌入向量(通过单层神经网络),长度为768(对于GPT-2)和12,288(对于ChatGPT的GPT-3)。同时,还有一个“次要路径”,它将标记的整数位置序列作为输入,并从这些整数创建另一个嵌入向量。最后,将来自令牌值和令牌位置的嵌入向量相加,以产生来自嵌入模块的最终嵌入向量序列。

为什么只需将令牌值和令牌位置嵌入向量相加?我认为这并没有特别科学之处。只是尝试了各种不同的方法,这似乎是有效的其中之一。而且,在神经网络领域中,“大致正确”的设置通常足以通过充分训练找到细节,而无需真正理解神经网络如何配置自己在工程水平上运行。

以下是嵌入模块的操作,它作用于字符串“hello hello hello hello hello hello hello hello hello bye bye bye bye bye bye bye bye bye”。

每个标记的嵌入向量元素显示在页面下方,横跨页面我们首先看到一系列“hello”嵌入,然后是一系列“bye”嵌入。上面的第二个数组是位置编码器 - 其略微随机的结构正好是所学习到的(在这种情况下为GPT-2)。

好吧,在嵌入模块之后就来到了变形金刚的“主要事件”:所谓的“注意力块”的序列(对于GPT-2有12个,对于ChatGPT’s GPT-3有96个)。这一切都非常复杂,并且让人想起典型的大型难以理解工程系统或生物系统。但无论如何,以下是单个“注意力块”的示意图(适用于GPT-2):

在每个这样的注意力块中,都有一组“注意头”(GPT-2为12个,ChatGPT的GPT-3为96个),每个头独立地在嵌入向量中不同的值块上运行。 (是的,我们不知道将嵌入向量分成几部分或其不同部分的含义是一个好主意;这只是那些已经被发现有效的事情之一。)

那么,注意力头做什么?基本上它们是一种“回顾”标记序列(即迄今为止生成文本),并以对于找到下一个标记有用形式来“打包过去”。 在上面第一节中,我们讨论了使用二元概率根据其直接前驱选择单词。转换器中的“关注”机制允许甚至更早期单词进行“关注”,从而可能捕获动词可以引用出现在句子之前多个单词处名词等方式。

更详细地说,注意力头所做的就是重新组合与不同标记相关联的嵌入向量块,并带有某些权重。例如,在第一个关注块(在GPT-2中)中具有以下针对以上字符串”hello, bye” 的(“回溯到标记序列开端”的) “重新组合权重”模式:

经过注意力头的处理后,生成的“重新加权嵌入向量”(对于GPT-2长度为768,对于ChatGPT的GPT-3长度为12,288)通过标准的“全连接”神经网络层传递。很难掌握这个层正在做什么。但是这里有一个使用它的768×768权重矩阵绘图(这里是针对GPT-2):


取64×64移动平均值,一些(类似随机游走的)结构开始显现:

这个结构是由什么决定的?最终,可能是人类语言特征的某种“神经网络编码”。但目前为止,这些特征可能还不太清楚。实际上,我们正在“打开ChatGPT(或至少是GPT-2)的大脑”,发现它非常复杂,并且我们并不理解——尽管最终它产生了可识别的人类语言。

好吧,在经过一个注意力块之后,我们得到了一个新的嵌入向量——然后依次通过其他注意力块(对于GPT-2总共有12个;对于GPT-3则有96个)。每个注意力块都有自己独特的“关注”和“全连接”权重模式。在这里,对于第一个关注头,“hello, bye”输入序列中的关注权重顺序如下:

这里是全连接层的(移动平均)“矩阵”:

有趣的是,尽管不同注意力块中的这些“权重矩阵”看起来非常相似,但权重大小分布可能会有所不同(并且并不总是高斯分布):

经过所有这些注意力块后,变压器的净效果是什么?本质上,它是将标记序列的原始嵌入集合转换为最终集合。而 ChatGPT 的特定方式是选择此集合中的最后一个嵌入,并“解码”以生成下一个应该出现的标记列表的概率。

所以这就是 ChatGPT 内部大致情况。它可能看起来很复杂(不仅因为其许多必然有些任意的“工程选择”),但实际上涉及到的最终元素非常简单。因为归根结底,我们处理的只是由“人造神经元”组成、每个神经元都执行将数字输入集合与某些权重相结合等简单操作的神经网络。

ChatGPT的原始输入是一组数字(到目前为止令牌的嵌入向量),当ChatGPT“运行”以生成新令牌时,这些数字只是通过神经网络层“涟漪”,每个神经元“做自己的事情”并将结果传递给下一层上的神经元。没有循环或“回溯”。所有东西都只是通过网络“前馈”。

这与典型计算系统(如图灵机)非常不同,在该系统中,结果会被相同的计算元素重复地“重新处理”。在此处-至少在生成给定输出标记方面-每个计算元素(即神经元)仅使用一次。

但在某种意义上,即使是在ChatGPT中仍然存在一个“外部循环”,它重复使用计算元素。因为当ChatGPT要生成新的标记时,它总是“读取”(即将其作为输入)之前出现的所有标记序列,包括ChatGPT自己先前“编写”的标记。我们可以认为这个设置意味着,在最外层至少涉及到一个“反馈循环”,尽管每次迭代都明确可见作为出现在所生成文本中的标记。

但让我们回到ChatGPT的核心:被重复用于生成每个标记的神经网络。从某种程度上来说,它非常简单:一整套相同的人工神经元。网络中有些部分只由(“全连接”)神经元层组成,在给定层上的每个神经元与之前一层上的每个神经元都连接(带有一些权重)。但特别是通过其变压器架构,ChatGPT具有更多结构化部分,在其中只连接不同层上特定的神经元。(当然,“所有神经元都已连接”,但有些权重值为零)。

此外,ChatGPT中神经网络的某些方面并不是最自然地被认为只包含“同质”层。例如,正如上面的标志性摘要所示,在注意力块内部有一些地方会“复制多个”传入数据,每个数据都通过不同的“处理路径”,可能涉及不同数量的层,并且仅在稍后重新组合。但是,虽然这可能是正在发生的事情的便捷表示法,但至少原则上总是可以考虑到“密集填充”的层,只需使一些权重为零即可。

如果看一下ChatGPT中最长路径,则涉及约400(核心)层-在某些方面并不算很多。但有数百万个神经元-共计1750亿个连接因此也就有了1750亿个权重。需要意识到的一件事情是:每次ChatGPT生成一个新令牌时,它都必须进行涉及所有这些权重之间计算。从实现角度来看,“按图层”将这些计算高度并行化成数组操作,并且可以在GPU上轻松完成。但对于每个生成的令牌还需要进行1750亿次计算(最终还要更多),因此使用ChatGPT生成长文本确实需要花费一定时间。

但最终,值得注意的是,所有这些操作——虽然它们各自都很简单——但在一起却能够做出如此出色的“类人”文本生成工作。必须再次强调(至少就我们所知),没有“最终理论原因”可以解释为什么会有这样的结果。事实上,正如我们将要讨论的那样,我认为我们必须把这看作是一个潜在惊人的科学发现:在像ChatGPT这样的神经网络中,不知何故竟能捕捉到人脑在语言生成方面所做到的本质特征。

hatGPT的训练

好的,现在我们已经概述了ChatGPT设置后的工作方式。但是它是如何被设置起来的呢?那1750亿个神经元网络中所有权重是如何确定的呢?基本上,这些权重都是通过大规模训练得出来的,基于人类撰写的大量文本语料库——包括网页、书籍等等。正如我们所说,即使有了所有这些训练数据,一个神经网络能够成功地生成“类似人类”的文本也并不明显。而且,再次强调一下,在实现这一点方面需要详细的工程设计。但ChatGPT带来了一个惊喜和发现:原来这样做是可能的!事实上,“仅”具有1750亿个权重值得神经网络可以制作出人类撰写文本内容“合理模型”。

在现代,有许多人类书写的数字化文本存在。公共网络上至少有数十亿篇人类书写的网页,总计可能达到万亿字。如果包括非公开网页,则数量可能至少大100倍。迄今为止,已经提供了超过500万本数字化图书(约占曾经出版的1亿左右图书的一百分之一),提供了另外约1000亿字的文本。这还没有提及从视频等中获得的语音转换成文本。(作为个人比较,我终身发表材料总量不到300万字,在过去30年里我写了大约1500万封电子邮件,并且总共打了大约5000万字——仅在过去几年里我就在直播中说了超过1000万个单词。是的,我会从所有这些内容中训练一个机器人)

但好吧,考虑到所有这些数据,如何用神经网络进行训练呢?基本流程与我们以上简单示例中所述非常相似:您展示一批样例,然后调整网络权重以最小化该网络对这些样例产生错误(“损失”)。 “反向传播”误差时主要昂贵之处在于每次执行此操作时通常会使网络中每个权重至少微小地改变,并且只是要处理大量权重。(实际的“反向计算”通常只比前向计算难一点点。)

使用现代GPU硬件,可以轻松地并行计算数千个示例批次的结果。但是,当涉及到实际更新神经网络中的权重时,当前方法要求基本上逐批次进行此操作。(是的,在这方面,实际大脑——具有其组合计算和存储元素——目前至少具有架构优势。)

即使是我们之前讨论的学习数字函数看似简单的情况,我们发现我们通常需要使用数百万个示例才能成功地训练一个网络,至少从头开始。那么这意味着为了训练一个“类人语言”的模型,我们需要多少样本?似乎没有任何基本的“理论”方法可以知道。但在实践中,ChatGPT已经成功地在几千亿个文本词汇上进行了训练。

它被喂入了一些文本多次,有些只被喂入一次。但不知怎么的它从所见到的文本中“得到了所需”。但是,在给定这样大量的文本进行学习时,需要多大规模的神经网络才能够“很好地学习”呢?同样地,在理论上还没有根据来回答这个问题。最终——正如下面将进一步讨论——人类语言和人们通常用它说话可能存在某种“总算法内容”。但接下来要问的问题是神经网络在基于该算法内容实施模型时会有多高效。同样地,我们并不知道——尽管ChatGPT取得了成功表现,并且表明其相当有效率。

最后值得注意的是:ChatGPT使用数百亿个权重来完成其任务——与其所接收的训练数据中单词(或标记)的总数相当。在某些方面,这也许令人惊讶(尽管ChatGPT较小规模的类比中也有经验观察到),因为似乎“能够很好地工作”的“网络大小”与“训练数据大小”是如此相近。毕竟,显然并不是因为所有来自网络、书籍等文本都以某种方式被直接存储在ChatGPT内部。实际上,在ChatGPT内部存在一堆数字——精度略低于10位——它们是所有文本结构的分布式编码。

换句话说,我们可以问人类语言的“有效信息内容”是什么,以及通常用它来表达什么。有语言示例的原始语料库,还有ChatGPT神经网络中的表示。这种表示很可能远离“算法最小化”的表示(如下所述)。但这是一种神经网络容易使用的表示形式。在这个表示中,训练数据似乎没有太多压缩;平均而言,一个单词只需要不到一个神经网络权重就能携带其“信息内容”。

当我们运行ChatGPT生成文本时,基本上每个权重都要使用一次。因此如果有n个权重,则需要进行约n次计算步骤——尽管实际上许多计算步骤通常可以在GPU中并行执行。但如果我们需要大约n个单词的训练数据来设置这些权重,则根据以上所述,我们可以得出结论:我们将需要大约n²次计算步骤来完成网络的训练——这就是为什么目前需要谈论数十亿美元培训工作量的原因了。

超越基础训练

培训ChatGPT的大部分工作都花在“展示”来自网络、书籍等大量现有文本上。但事实证明,还有另一个——显然相当重要的——部分。

一旦它完成了从原始文本语料库中获得的“原始训练”,ChatGPT内部的神经网络就准备好开始生成自己的文本,从提示信息等继续进行。但是,尽管这样产生的结果通常似乎合理,但对于较长的文本片段而言,它们往往会以非人类方式“偏离”。这不是通过对文本进行传统统计可以轻易检测到的东西。但实际阅读该文本内容时却容易被真正人类注意到。

构建ChatGPT时关键思想之一是,在像网络这样“被动阅读”的事物之后再加入另一个步骤:让真正人类积极与ChatGPT互动,并查看其生成内容,并在效果上给出反馈,“如何成为良好聊天机器人”。但神经网络如何使用该反馈呢?第一步只是让人类评价神经网络产生的结果。然后建立另一个神经网络模型来预测那些评级。但现在可以运行这个预测模型——实质上像一个损失函数——在原始网络上,从而允许该网络通过已经给出的人类反馈进行“调整”。实践中的结果似乎对系统成功产生“类人”输出有很大影响。

总的来说,有趣的是,“最初训练”的网络似乎需要很少的“刺激”就能使其朝特定方向有用地发展。人们可能认为,要使网络表现得好像它“学到了新东西”,必须运行训练算法、调整权重等。

但事实并非如此。相反,似乎只需在您提供的提示中告诉ChatGPT一次即可,并且当它生成文本时可以成功利用您告诉它的内容。再次强调这种方法有效是我认为理解ChatGPT“真正做什么”以及它与人类语言和思维结构之间关系的重要线索。

这确实有点类似于人:至少经过所有预先培训后,你只需告诉它一次某些内容,然后它就可以“记住”——至少“足够长时间”,以使用该信息生成一段文本。那么在这种情况下发生了什么?可能是“你可能会告诉他任何事情都已经存在于其中某个地方”,而你只是引导他到正确位置。但这看起来不太可信。相反,更有可能的是元素已经存在其中,但具体定义由类似于元素之间轨迹之类的东西确定,并且当您告诉它某些内容时,就是在引入这种轨迹。

实际上,就像对于人类一样,如果你告诉它一些奇怪和意外的东西完全不符合它已知的框架,它似乎无法成功地“整合”这个信息。只有当它基本上在已有的框架之上以相当简单的方式运行时,才能够“整合”这个信息。

值得再次指出的是,神经网络所能“捕捉”的内容必然存在“算法限制”。告诉它形式为“这个变成那个”的浅显规则等等,并且神经网络很可能可以很好地表示和复制这些规则——实际上从语言中所了解到的内容将给予其一个立即可遵循的模式。但是如果试图为涉及许多潜在计算不可约步骤的实际深度计算提供规则,则无法正常工作。(请记住,在每一步中,除了生成新标记外,它总是仅向前馈送数据而没有回路。)

当然,该网络可以学习特定“不可约”计算问题的答案。但是一旦存在组合数量级别以上数量级别以上数量级别以上数量级别以上可能性,则此类表格查找方法将无效。因此,请注意:与人类一样,“神经网络”需要使用实际的计算工具。(是的,Wolfram|Alpha和Wolfram Language非常适合,因为它们已经被构建成“谈论世界中的事物”,就像语言模型神经网络一样。)

是什么让ChatGPT真正工作起来?

人类语言以及生成它所涉及的思维过程一直被认为是复杂性的巅峰。事实上,人脑——其“仅有”约1000亿个神经元(和可能达到1万亿个连接)的网络似乎能够负责这一切,这似乎相当不可思议。或许,一个人可以想象,大脑除了神经元网络之外还有其他未被发现的物理层面。但现在我们通过ChatGPT得到了重要新信息:我们知道一个纯粹、人造神经网络与大脑中神经元数量相当多的连接数能够出奇地好地生成人类语言。

没错,这仍然是一个庞大而复杂的系统——其中权重与当前世界上所有文本单词数量相同。但从某种程度上来说,很难相信所有语言丰富性和它所能谈论的事情都可以包含在如此有限的系统中。部分原因无疑反映了普遍现象(最初在规则30示例中首次显现),即计算过程实际上可以极大地放大系统表面复杂性,即使它们基础规则很简单也是如此。但实际上,正如我们上面讨论的那样,ChatGPT中使用的神经网络往往是特别构建的,以限制这种现象及其相关的计算不可约简性,并使它们更易于训练。

那么,像ChatGPT这样的东西如何能够在语言方面取得如此大的进展呢?我认为基本答案是,语言在某种根本层面上比它看起来要简单。这意味着,即使是具有最终直接的神经网络结构的ChatGPT也成功地能够“捕捉”人类语言及其背后思维的实质。而且,在训练过程中,ChatGPT以某种方式“隐含地发现”了使这一切成为可能的语言(和思考)规律。

我认为ChatGPT的成功给我们提供了一个基础和重要科学证据:它表明我们可以期望存在一些全新、重要的“语言法则”,以及有效地探索其中所涉及到“思想法则”。在作为神经网络构建出来的ChatGPT中,这些法则充其量只是隐含存在。但如果我们能够将这些法则变得显式化,则有潜力以更加直接、高效和透明化方式执行类似于ChatGPT所做事情。

那么好吧,这些规律会是什么样子呢?最终它们必须给我们提供一定程度上关于如何组合语言——以及用它说出来之物的指导。稍后我们将讨论“查看ChatGPT内部”可能如何能够给我们一些关于这方面的提示,以及从构建计算语言中所了解到的内容提供前进道路。但首先,让我们讨论两个长期已知的“语言法则”的例子——以及它们与ChatGPT操作之间的关系。

第一个是语言句法结构。语言不仅仅是单词随意组合而成的混乱堆积物。相反,有(相当)明确的文法规则来定义不同类型单词可以如何组合:例如,在英语中,名词可以由形容词修饰并跟在动词后面,但通常两个名词不能紧挨着放置。这种文法结构可以(至少近似地)通过一组规则来捕捉,这些规则定义了类似于“分析树”的东西如何被拼接起来:

ChatGPT并没有明确的了解这些规则。但在其训练中,它会隐含地“发现”它们,并且似乎很擅长遵循它们。那么这是如何工作的呢?从一个“大局”层面来看,还不太清楚。但为了获得一些见解,也许看一个更简单的例子会有所帮助。

考虑由(和)序列组成的“语言”,其语法规定括号应该始终保持平衡,如表示为类似于分析树:

我们能训练神经网络生成“语法正确”的括号序列吗?在神经网络中处理序列的方法有很多种,但我们可以使用变压器网络,就像ChatGPT一样。给定一个简单的变压器网络,我们可以开始将语法正确的括号序列作为训练示例输入。一个微妙之处(实际上也出现在ChatGPT生成人类语言时)是除了我们的“内容标记”(这里是“(”和“)”)外,还必须包含一个“结束”标记,用于指示输出不应再继续下去(即对于ChatGPT来说,已到达故事的“结尾”)。

如果我们只设置一个具有8个头部和长度为128的特征向量的注意块的变压器网络(ChatGPT也使用长度为128 的特征向量,但每个注意块都有96 个头),那么似乎不可能使其学习关于括号语言方面太多知识。但是通过2个注意块进行设置后,在大约提供1000万个示例后学习过程似乎会收敛——并且与变压器网络常见情况相同地表现出更多示例只会降低性能。

因此,在这个网络中,我们可以做类似于ChatGPT所做的操作,并要求计算下一个标记的概率——在括号序列中:

在第一种情况下,网络“非常确定”序列不能在此结束——这很好,因为如果是这样的话,括号将不平衡。然而,在第二种情况下,它“正确地识别出”序列可以在此结束,尽管它也“指出”,可以“重新开始”,放置一个“(” ,可能会跟着一个“)” 。但是糟糕的是,即使有其400,000个左右费力训练的权重,它仍然说有15%的概率将“)”作为下一个标记——这是不正确的,因为那必定会导致括号不平衡。

如果我们要求网络逐渐更长地完成(’s)序列,则会得到以下结果:

是的,网络在一定长度范围内表现得很好。但是随后它开始失败。这是神经网络(或机器学习)中“精确”情况下常见的问题。人类可以“一眼解决”的情况,神经网络也可以解决。但是需要进行“更算法化”的操作(例如显式计数括号以查看它们是否关闭)的情况下,神经网络似乎会“计算能力不足”,无法可靠地执行。(顺便说一句,即使完整的ChatGPT当前版本也很难正确匹配长序列中的括号)

那么对于像ChatGPT和英语这样的语言语法来说意味着什么?括号语言非常简洁 - 更多地属于“算法故事”。但在英语中,根据单词和其他提示局部选择,“猜测”何时符合文法要求更加现实。而且,是的,在这方面神经网络做得更好 - 即使可能会错过某些形式上正确但人类也可能忽略掉的案例。但主要观点是:事实上有一个整体句法结构存在于该语言中,并且具有所有正则性所暗示的限制程度,在某种程度上限制了神经网络必须学习的“程度”。关键的“自然科学式”观察是,像ChatGPT中的变压器架构似乎成功地能够学习类似于嵌套树状句法结构的语言结构(至少在某种近似情况下存在于所有人类语言中)。

语法为语言提供了一种约束。但显然还有更多。像“Inquisitive electrons eat blue theories for fish”这样的句子在文法上是正确的,但不是人们通常会说出来的话,并且如果ChatGPT生成它,则不会被认为是成功 - 因为基本上没有意义。

但是否有一般方法可以判断一个句子是否有意义?传统上没有整体理论。但这是ChatGPT经过数十亿个(可能具有含义)从网络等处训练而隐含“开发出理论”的东西。

这个理论会是什么样子呢?嗯,有一个小角落基本上已经被人们知道了两千年,那就是逻辑。当然,在亚里士多德发现它的三段论形式中,逻辑基本上是一种说法,即遵循某些模式的句子是合理的,而其他句子则不合理。因此,例如,“所有X都是Y。这不是Y, 所以它不是X”(如“所有鱼都是蓝色的。这不是蓝色的,所以它不是鱼。”)就很合理。正如人们可以略微任性地想象亚里士多德通过大量修辞学例子(类似于“机器学习风格”)来发现三段论逻辑一样,我们也可以想象在ChatGPT 的训练中它将能够通过查看网络上的大量文本等方式“发现三段论逻辑”。 (而且,在这方面 ChatGPT 可以产生包含基于三段论逻辑等内容的“正确推断”的文本;但当涉及到更复杂的形式化逻辑时情况完全不同——我认为出现失败可能与括号匹配失败原因相同)

但除了狭窄范围内关于逻辑之外,还能说些什么关于如何系统地构建(或识别)甚至是合理的文本吗?是的,有像 Mad Libs 这样使用非常特定“短语模板”的东西。但不知怎么的 ChatGPT 隐含了一种更普遍的方法来做到这一点。也许除了“当你拥有1750亿个神经网络权重时它就会发生”之外,没有其他可以说的了。但我强烈怀疑存在一个更简单、更强大的故事。

意义空间和语义运动定律

我们上面讨论过,在ChatGPT中,任何一段文本都可以被有效地表示为一个数字数组,我们可以将其视为某种“语言特征空间”中点的坐标。因此,当ChatGPT继续一段文本时,这相当于在语言特征空间中跟踪轨迹。但现在我们可以问:是什么使得这条轨迹对应于我们认为有意义的文本?或者说是否存在某种“语义运动定律”,定义或至少限制了在保持“有意义性”的同时,语言特征空间中的点如何移动?

那么这个语言特征空间是什么样子的呢?如果我们将这样一个特征空间投影到二维平面上,以下是单词(此处为普通名词)可能被排列的示例:

我们在上面看到了另一个例子,基于代表植物和动物的单词。但两种情况的重点都是“语义相似的单词”被放置在附近。

作为另一个例子,这里是不同词性对应的单词如何排列:

当然,一个单词通常不只有“一个意思”(或者不一定对应于一个词性)。通过观察包含某个单词的句子在特征空间中的布局,人们经常可以“分辨出”不同的含义——就像这里针对“鹤”这个词(是指鸟还是机器?)的例子一样。

好的,那么我们至少可以认为这个特征空间将“意思相近的单词”放在了这个空间中靠近的位置。但是我们能够在这个空间中找到什么样的额外结构呢?例如,是否存在某种“平行传输”的概念来反映该空间中的“平坦性”?了解这一点的一种方法是查看类比:

是的,即使我们将其投影到二维平面上,通常也至少有一点“扁平”的迹象,尽管这并不普遍。

那么轨迹呢?我们可以查看ChatGPT提示在特征空间中遵循的轨迹 - 然后我们可以看到ChatGPT如何延续它:

这里显然没有“几何上明显”的运动定律。这一点并不令人惊讶;我们完全预料到这将是一个更加复杂的故事。例如,即使存在“语义运动定律”,它最自然地陈述在哪种嵌入(或实际上是哪些“变量”)也远非明显。

在上面的图片中,我们展示了“轨迹”的几个步骤——每一步我们都选择ChatGPT认为最有可能的单词(“零温度”情况)。但我们还可以询问在给定点时下一个可能出现的单词及其概率是什么:

在这种情况下,我们看到的是一个高概率词汇的“扇形”,似乎在特征空间中朝着更或多或少明确的方向前进。如果我们继续前进会发生什么?以下是随着轨迹“移动”而出现的连续“扇形”:

这是一个包含40个步骤的3D表示:

是的,这似乎很混乱,并且并没有特别鼓励人们期望通过经验研究“ChatGPT内部正在做什么”来确定“数学物理类”的“语义运动定律”。但也许我们只是看错了变量(或坐标系),如果我们只看对了,我们就会立即看到ChatGPT正在做一些像沿着测地线那样的“数学物理简单”事情。但目前为止,我们还没有准备好从其“内部行为”中“经验性解码”,以确定ChatGPT已经发现有关如何将人类语言组合在一起的内容。

语义语法和计算语言的力量

生产有意义的人类语言”需要什么?过去,我们可能认为这只能由人脑完成。但现在我们知道,ChatGPT的神经网络也可以相当好地完成这项任务。不过,也许这就是我们所能达到的极限了,并且没有更简单或更易于理解的方法可行。但我强烈怀疑ChatGPT成功背后隐含着一个重要的“科学”事实:有关有意义的人类语言结构和简单性比我们以往所知道的要多得多——最终可能会有相当简单明了的规则来描述如何组合这种语言。

正如上面提到的那样,句法语法给出了用于将与词性等不同部分对应的单词组合成人类语言中句子结构规则。但为了处理含义,我们需要进一步思考。其中一种做法是不仅考虑句法文法而且还需考虑语义文法。”

为了语法的目的,我们识别像名词和动词这样的东西。但是为了语义学的目的,我们需要“更细微”的分化。因此,例如,我们可能会确定“移动”概念和一个“保持其独立于位置身份不变”的“对象”概念。每个这些“语义概念”都有无数具体例子。但是对于我们语义文法而言,我们只需拥有一些基本规则即可,基本上说,“物体”可以“移动”。关于所有这些如何工作还有很多要说(其中一部分我以前已经说过)。但在此我将满足于仅发表几点评论,并指出一些未来可能性。

值得注意的是,即使根据语义文法句子完全没有问题也并不意味着它在实践中已被实现(甚至可能无法实现)。 “大象去月球旅行”毫无疑问会通过我们的语义文法测试,但它肯定尚未在我们真正存在世界中被实现(至少到目前为止)- 尽管它绝对适用于虚构世界。

当我们开始谈论“语义语法”时,我们很快就会问:“它下面是什么?”它假设了什么样的“世界模型”?句法语法实际上只涉及从单词构建语言。但是,一个语义语法必然涉及某种“世界模型”,这个模型作为一种“骨架”,在其中可以添加由实际单词组成的语言。

直到最近,我们可能认为(人类)语言将是描述我们的“世界模型”的唯一通用方式。几个世纪前已经开始对特定事物进行形式化处理,尤其基于数学。但现在有了更普遍的形式化方法:计算机语言。

而且,没错,在过去四十多年中(现在体现在 Wolfram 语言中),这一直是我的大项目:开发精确的符号表示,能够尽可能广泛地讨论关于事物和抽象概念方面的内容。因此,例如城市、分子、图像和神经网络都有符号表示,并且我们内置了关于如何计算这些东西的知识。

经过几十年的工作后,我们以这种方式覆盖了许多领域。但过去,我们并没有特别处理“日常话题”。 在“I bought two pounds of apples”中,我们可以轻松地表示(并进行营养和其他计算)“两磅苹果”。但是我们还没有一个符号表示“我买了”的方法。

这一切都与语义语法的概念有关——目标是拥有一个通用的符号“构建工具包”来表示概念,这将为我们提供什么可以与什么相配合的规则,从而为我们可能转化成人类语言的“流程”提供规则。

但假设我们已经拥有了这种“符号话语语言”。那么我们会怎样使用它呢?我们可以开始生成“局部意义文本”。但最终,我们很可能希望获得更多“全球意义”的结果——这意味着需要更多地计算实际存在或发生在世界上(或者也许是某个一致的虚构世界)的内容。

现在,在 Wolfram 语言中,我们内置了大量关于各种事物的计算知识。但对于完整的符号话语语言,我们还必须建立关于世界上普遍事物额外的“演算法则”:如果一个对象从 A 移动到 B 再从 B 移动到 C,则它就从 A 移动到 C 等等。

给定一个符号话语语言,我们可以用它来制作“独立声明”。但是,我们也可以像 “Wolfram|Alpha 风格” 一样使用它来询问世界问题。或者使用它来陈述自己“想要实现的事情”,可能需要一些外部激活机制。或者我们可以用它来做出断言——也许是关于实际世界,也许是关于我们正在考虑的某个特定世界,无论是虚构还是其他。

人类语言基本上不精确,这不仅因为它没有与特定计算实现相联系,而且其含义基本上只由其用户之间的“社会契约”定义。但计算语言天生具有一定的精度——因为最终它所指定的内容总能够在计算机上“明确地执行”。人类语言通常可以逃脱一定程度的模糊性。(当我们说 “行星” 时是否包括系外行星等?)但在计算语言中,我们必须对所有区别进行精确定义和清晰表述。

通常,在编写计算语言名称时利用普通人类语言很方便。但他们在计算语言中所具有的意义必然是准确无误,并可能涵盖典型人类使用方式中某些特殊内涵。

如何确定适用于一般符号话语语言的基本“本体论”?嗯,这并不容易。也许这就是为什么自亚里士多德两千多年前做出原始开端以来,在这些方面几乎没有什么进展。但今天我们已经知道如何计算地思考世界(而且从物理项目和ruliad的概念中获得“基础形而上学”),这确实有所帮助。

但在ChatGPT的背景下,所有这些意味着什么?通过其培训,ChatGPT有效地“拼凑”了相当数量(相当惊人)的语义语法。但它非常成功使我们有理由认为构建更完整的计算机语言形式是可行的。与迄今为止我们已经了解到关于ChatGPT内部情况不同,我们可以期望设计计算机语言以便人类能够轻松理解。

当我们谈论语义语法时,可以将其类比为三段论逻辑。起初,三段论逻辑本质上是关于用人类语言表达的陈述的规则集合。但是(是的,在两千年后),当形式逻辑被发展出来时,三段论逻辑的最初基本结构现在可以用于构建巨大的“正式塔”,其中包括现代数字电路运算等操作。因此,我们可以期望更一般化的语义语法也会如此。起初,它可能只能处理简单模式,例如文本表示方式。但是一旦整个计算机语言框架建立起来了,我们就可以期望它能够被用于建立高耸入云、允许我们以精确和正式方式处理各种以前从未可接近过我们、除了通过带有所有含糊性质地“底层”人类语言之外。

我们可以认为计算机语言和语义语法的构造代表着对事物进行终极压缩的一种形式。因为它使得我们能够谈论到可能性实质而不必考虑普通人类口头表达中存在的所有“措辞”。而且我们也可以把ChatGPT 的强大视作某种相似之处:因为在某种意义上,它也已经“钻研到”了这一点,即可以“以语义有意义的方式组合语言”,而不必考虑不同可能的措辞。

那么如果我们将ChatGPT应用于基础计算机语言会发生什么呢?计算机语言可以描述可能性。但是还可以添加一个“流行”的概念——例如基于阅读网络上所有内容。但是然后,在底层操作计算机语言时,像ChatGPT这样的系统就立即和根本地访问了利用潜在不可约简运算的终极工具。这使得它不仅能够“生成合理文本”,而且可以期望解决关于该文本是否实际上对世界或者所谈论的事物做出了“正确”陈述等问题。

那么…ChatGPT在做什么,为什么它有效?

ChatGPT的基本概念在某种程度上相当简单。从网络、书籍等大量人类创作文本样本开始,然后训练神经网络生成“类似于这些”的文本。特别地,使其能够从“提示”开始,然后继续生成“与其训练内容相似”的文本。

正如我们所见,在ChatGPT中实际的神经网络由非常简单的元素组成——尽管有数十亿个。而神经网络的基本操作也非常简单,基本上是对每个新词(或部分词)产生输入时,“将其通过元素”(没有任何循环等)。

但值得注意和意外的是,这个过程可以产生成功“像”网页、书籍等中存在的文本,并且它不仅是连贯的人类语言,还会利用它所阅读到内容来表达一些东西。“它说出来”的东西符合其提示,并且并不总是说出全局意义上正确计算结果——因为(例如没有使用Wolfram|Alpha 的 “计算超级能力”),它只是根据培训材料中听起来像什么来表达自己。

ChatGPT的特定工程使其非常引人注目。但最终(至少在它能够使用外部工具之前),ChatGPT只是从其积累的“传统智慧的统计数据”中提取了一些“连贯的文本线索”。但令人惊奇的是,结果与人类非常相似。正如我所讨论的那样,这表明某些东西至少在科学上非常重要:人类语言(以及背后思考模式)在结构上比我们想象中更简单、更符合“法则”。 ChatGPT已经隐含地发现了这一点。但我们可以通过语义语法、计算机语言等方式来明确地暴露它。

ChatGPT生成文本时所做的事情非常令人印象深刻——结果通常与我们人类产生的内容非常相似。那么这是否意味着ChatGPT像大脑一样工作?它底层的人工神经网络结构最终是基于对大脑理想化建模而得出来的。当我们产生语言时,很可能有很多方面都与此相似。

当涉及到训练(也称为学习)不同类型硬件和当前计算机(以及可能还有一些未开发完善算法思路)的大脑时,ChatGPT被迫使用一种策略,这种策略可能与大脑相比有所不同(在某些方面效率要低得多)。还有另外一件事:与典型的算法计算甚至不同,在ChatGPT内部没有“循环”或“重新计算数据”。这必然限制了它的计算能力——即使是对于当前的计算机来说也是如此,但肯定是相对于大脑而言。

目前尚不清楚如何“修复”这个问题并仍保持系统合理高效地训练。但做到这一点将可能使未来的ChatGPT执行更多类似于大脑的任务。当然,有很多事情人类大脑做得不太好——特别涉及到等价为基本运算量级别的问题。对于这些问题,无论是人类大脑还是像ChatGPT之类的东西都需要寻求“外部工具”,例如Wolfram语言。

但目前来看,看到ChatGPT已经能够做到什么是很令人兴奋的。在某种程度上,这是一个伟大的例子,说明了基本科学事实:大量简单的计算元素可以做出卓越而意想不到的事情。但它也为我们更好地理解人类语言和思维过程这一核心特征及其原则提供了或许有史以来最好的动力。


为了方便阅读,我也提供了电子书epub版本,下载后导入到微信读书等软件,可以边听边看文字。
ChatGPT工作原理 电子书下载






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



axum中文简述

以下文档基于axum Version 0.6.17

axum是一个专注于极简高效和模块化的web应用程序框架。

高级特性

  • 使用无宏API将请求路由到处理程序。
  • 使用提取器声明性地解析请求。
  • 简单和可预测的错误处理模型。
  • 用最少的样板文件生成响应。
  • 充分利用中间件、服务和实用程序的towertower-http生态系统。

最后一点是“axum”区别于其他框架的地方。axum没有自己的中间件系统,而是使用tower::Service。这意味着“axum”可以免费获得超时、跟踪、压缩、授权等。它还允许您与使用‘ hyper ‘‘ tonic ‘编写的应用程序共享中间件。

兼容性

axum是为配合tokiohyper设计的,运行时层和传输层的独立性不是目标,至少目前不是。

示例

axum的 “Hello, World!” :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use axum::{
routing::get,
Router,
};

#[tokio::main]
async fn main() {
// build our application with a single route
let app = Router::new().route("/", get(|| async { "Hello, World!" }));

// run it with hyper on localhost:3000
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}

注意使用#[tokio::main]需要启用tokio的macrosrt-multi-thread功能,或者只启用full来启用所有功能(cargo add tokio—features macros,rt-multi-thread)。

路由(ROUTING)

Router 用于设置哪些路径指向哪些服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
use axum::{Router, routing::get};

// our router
let app = Router::new()
.route("/", get(root))
.route("/foo", get(get_foo).post(post_foo))
.route("/foo/bar", get(foo_bar));

// which calls one of these handlers
async fn root() {}
async fn get_foo() {}
async fn post_foo() {}
async fn foo_bar() {}

细节可以查看 Router 

处理器(Handlers)

在axum中,处理器(“handler”)是一个异步函数,它接受零个或多个“extractors”作为参数,并返回可以转换为response的内容

处理器是应用程序逻辑存在的地方,而axum应用程序是通过处理器之间的路由构建的。

请参见‘ handler ‘了解处理程序的更多详细信息。

提取器(Extractors)

提取器是一种实现FromRequestFromRequestParts的类型。提取器是您如何分离传入请求以获得处理程序所需的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
use axum::extract::{Path, Query, Json};
use std::collections::HashMap;

// `Path` gives you the path parameters and deserializes them.
async fn path(Path(user_id): Path<u32>) {}

// `Query` gives you the query parameters and deserializes them.
async fn query(Query(params): Query<HashMap<String, String>>) {}

// Buffer the request body and deserialize it as JSON into a
// `serde_json::Value`. `Json` supports any type that implements
// `serde::Deserialize`.
async fn json(Json(payload): Json<serde_json::Value>) {}

更多细节可以参看 extract .

响应(Responses)

任何实现IntoResponse的东西都可以从处理器返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use axum::{
body::Body,
routing::get,
response::Json,
Router,
};
use serde_json::{Value, json};

// `&'static str` becomes a `200 OK` with `content-type: text/plain; charset=utf-8`
async fn plain_text() -> &'static str {
"foo"
}

// `Json` gives a content-type of `application/json` and works with any type
// that implements `serde::Serialize`
async fn json() -> Json<Value> {
Json(json!({ "data": 42 }))
}

let app = Router::new()
.route("/plain_text", get(plain_text))
.route("/json", get(json));

更多细节参看 response 。

错误处理(Error handling)

axum旨在提供一个简单且可预测的错误处理模型,这意味着将错误转换为响应很简单,并且可以保证所有错误都得到处理。

参见error_handling了解更多关于axum错误处理模型以及如何优雅地处理错误的详细信息。

中间件(Middleware)

为axum编写中间件有几种不同的方法。详见‘中间件’

与处理器共享状态(Sharing state with handlers)

在处理程序之间共享某些状态是很常见的。例如,可能需要共享到其他服务的数据库连接或客户端池。

最常见的三种方法是:

  • 使用State提取器
  • 使用请求扩展
  • 使用闭包捕获

使用 State extractor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use axum::{
extract::State,
routing::get,
Router,
};
use std::sync::Arc;

struct AppState {
// ...
}

let shared_state = Arc::new(AppState { /* ... */ });

let app = Router::new()
.route("/", get(handler))
.with_state(shared_state);

async fn handler(
State(state): State<Arc<AppState>>,
) {
// ...
}

如果可能的话,您应该更喜欢使用State,因为它更类型安全。缺点是它不如Request extensions 具有动态性。

有关访问状态的更多细节,请参见State

使用 request extensions

在处理器中提取状态的另一种方法是使用Extension 作为层和提取器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use axum::{
extract::Extension,
routing::get,
Router,
};
use std::sync::Arc;

struct AppState {
// ...
}

let shared_state = Arc::new(AppState { /* ... */ });

let app = Router::new()
.route("/", get(handler))
.layer(Extension(shared_state));

async fn handler(
Extension(state): Extension<Arc<AppState>>,
) {
// ...
}

这种方法的缺点是,如果您尝试提取一个不存在的扩展,可能是因为您忘记添加中间件,或者因为您提取了错误的类型,那么您将得到运行时错误(特别是500 Internal Server Error 响应)。

使用闭包捕获(closure captures)

State也可以使用闭包捕获直接传递给处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
use axum::{
Json,
extract::{Extension, Path},
routing::{get, post},
Router,
};
use std::sync::Arc;
use serde::Deserialize;

struct AppState {
// ...
}

let shared_state = Arc::new(AppState { /* ... */ });

let app = Router::new()
.route(
"/users",
post({
let shared_state = Arc::clone(&shared_state);
move |body| create_user(body, shared_state)
}),
)
.route(
"/users/:id",
get({
let shared_state = Arc::clone(&shared_state);
move |path| get_user(path, shared_state)
}),
);

async fn get_user(Path(user_id): Path<String>, state: Arc<AppState>) {
// ...
}

async fn create_user(Json(payload): Json<CreateUserPayload>, state: Arc<AppState>) {
// ...
}

#[derive(Deserialize)]
struct CreateUserPayload {
// ...
}

这种方法的缺点是它比使用‘ State ‘或extensions更冗长。

为axum构建集成

想要为FromRequestFromRequestParts,或IntoResponse提供实现的lib作者应该依赖于axum-corecrate,而不是axum axum-core 包含核心类型和特征,不太可能收到破坏性更改。

需要的依赖

为了使用axum,你还需要引入一些依赖项

1
2
3
4
5
[dependencies]
axum = "<latest-version>"
hyper = { version = "<latest-version>", features = ["full"] }
tokio = { version = "<latest-version>", features = ["full"] }
tower = "<latest-version>"

hyper和tokio的“full”功能并不是必须的,但这是最简单的入门方法。

注意hyper::Server 是由axum重新导出的,所以只需要这个,那么你不必显式地依赖hyper。

Tower也不是严格必要的,但有助于测试。请参阅回购中的测试示例,以了解有关测试axum应用程序的更多信息。

更多示例

axum repo包含许多示例,展示了如何将所有部分组合在一起。

功能标志

axum使用一组feature flags来减少已编译和可选依赖项的数量。

可选特性包括:

Name Description Default?
headers 通过TypedHeader使得提取已输入标题成为可能 No
http1 启用 Hyper 的 HTTP1 功能 Yes
http2 启用 Hyper 的 HTTP2 功能 No
json 启用Json类型和一些类似的便利功能。 Yes
macros 启用可选实用宏 No
matched-path 使得能够捕获每个请求的路由路径和MatchedPath提取器。 Yes
multipart 使用Multipart使得解析multipart/form-data请求成为可能 No
original-uri 使得能够捕获每个请求的原始URI和OriginalUri提取器。 Yes
tokio 使得Tokio成为依赖项,并提供axum::ServerSSEextract::connect_info类型。 Yes
tower-log Enables tower’s log feature Yes
tracing 记录内置提取器的拒绝。 No
ws 通过extract::ws启用WebSockets支持 No
form 启用Form提取器 Yes
query 启用Query提取器 Yes





关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



Rust原子性和锁(前言)

本文为《Rust Atomics and Locks》翻译文章,原文为:https://marabos.nl/atomics/preface.html
仅供学习使用

前言

Rust在让系统编程更加可用的方面发挥了重要作用,然而,诸如原子性和内存排序之类的底层并发专题仍然经常被认为是一些神秘的内容,最好留给一小群专家来研究。

在过去几年研究Rust的实时控制系统和Rust标准库的过程中,我发现网上关于原子性(Atomics)和相关主题的可用信息只覆盖了我们真正想了解的内容的很小部分。许多资源完全集中在C和C++上,这使得它很难与Rust的(内存和线程)安全和类型系统的概念形成联系。涵盖了抽象理论细节的资源,如C++的内存模型,通常只是模糊地解释了它与实际硬件的关系。有许多资源涵盖了实际硬件的每个细节,例如处理器指令和缓存一致性,但是形成一个整体的理解通常需要从许多不同的地方收集零碎的信息来了解。

本书试图将相关信息放在一个地方,并将它们串联起来,为您提供构建正确的、安全的且符合我们使用习惯的并发原语所需的一切,同时充分了解底层硬件和操作系统的角色,以便能够做出设计决策和基本的优化权衡。

谁适合这本书

这本书的主要读者是Rust开发人员,他们希望了解更多关于低级并发性的知识。此外,这本书也适合那些还不太熟悉Rust,但想从Rust的角度了解底层并发的人。

假设你了解Rust的基础知识,最近安装了Rust编译器,并且知道如何使用“cargo”编译和运行Rust代码。Rust中对并发性很重要的概念在相关的时候会被简要地解释,所以不需要事先了解Rust并发性。

章节预览

这本书由十章组成。以下是每一章的内容,以及值得期待的内容:

第一章——Rust并发的基础知识

本章介绍了Rust中实现基础并发所需要的所有工具和概念,比如线程、互斥、线程安全、共享和独占引用、内部可变性等等,这些都是本书其余部分的基础。对于熟悉这些概念的有经验的Rust程序员,本章可以作为快速复习。对于那些从其他语言中了解这些概念但对Rust还不是很熟悉的人来说,本章将迅速为您补充书中其他部分可能需要的Rust特定知识。

第二章——原子性(Atomics)

在第二章中,我们将学习Rust的原子类型及其所有操作。我们从简单的加载和存储操作开始,逐步构建到更高级的“比较和交换循环”,通过几个真实的用例作为可用的示例来探索每个新概念。虽然内存排序与每个原子操作相关,但这个主题将留待下一章讨论。本章只介绍了“轻松的”内存排序就足够了的情况,这种情况比人们想象的要多。

第三章——内存排序(Memory Ordering)

在学习了各种原子操作和如何使用它们之后,第三章介绍了本书最复杂的主题:内存排序。我们将探索内存模型是如何工作的,什么是先于发生的关系以及如何创建它们,所有不同的内存顺序意味着什么,以及为什么顺序一致可能不是所有问题的答案。

第四章——创建自旋锁

在学习了理论之后,我们将在接下来的三章中通过构建几个常见并发原语的我们自己的版本将其付诸实践。第一章很短,我们实现了一个自旋锁。我们将从一个非常小的版本开始,将释放和获取内存排序付诸实践,然后我们将探索Rust的安全概念,将其转变为符合人体工程学和难以滥用的Rust数据类型。

第五章——创建线程间的通信渠道

在第5章中,我们将从头开始实现one-shot channel的一些变体,这是一个可用于将数据从一个线程发送到另一个线程的原语。从一个非常简单但完全“不安全”的版本开始,我们将通过几种方法来设计一个安全的界面,同时考虑设计决策及其后果。

第六章——手动创建Arc

在第六章中,我们将进行一个更具挑战性的内存排序难题。我们将从头开始实现我们自己版本的原子引用计数。在添加了对弱指针的支持并对其性能进行优化之后,我们的最终版本将与Rust的标准“std::sync::Arc”类型几乎相同。

第七章——深入处理器

第七章是对所有底层细节的深入探讨。我们将探索处理器层面发生了什么,原子操作背后的“汇编指令”在两种最流行的处理器架构上是什么样子,缓存是什么以及它如何影响代码的性能,我们还将讲解硬件级内存模型的剩余部分。

第八章——操作系统原语

在第8章中,我们承认有些事情没有操作系统内核的帮助是做不到的,并了解Linux、macOS和Windows上都有哪些可用的功能。我们将讨论POSIX系统上通过pthreads可用的并发原语,了解我们可以用Windows API做什么,并了解Linux 的futex syscall做什么。

第九章——手动打造锁

利用我们在前几章学到的知识,在第9章中,我们将从头开始构建互斥锁条件变量读写锁的几个实现。对于这些问题,我们将从一个最小但完整的版本开始,然后我们将尝试以各种方式优化它。使用一些简单的基准测试,我们将发现在讨论各种设计权衡时,我们在优化方面的尝试并不总是能够提高性能。

第十章——创意与灵感

最后一章确保你在读完这本书后不会陷入空虚,而是留下一些想法和灵感,用你的新知识和技能来构建和探索事物,也许会开启一段深入底层并发性的令人兴奋的旅程。

代码示例

本书中的所有代码都是针对Rust 1.66.0编写的,并使用Rust 1.66.0进行测试,Rust 1.66.0于2022年12月15日发布。早期版本不包括本书中使用的所有功能。不过,以后的版本应该可以正常工作。

为简洁起见,代码示例不包括’ use ‘语句,除非第一次引入标准库中的新项。为了方便起见,下面的前奏可以用来导入编译本书中任何代码示例所需的所有内容:

1
2
3
4
5
6
7
8
9
10
11
12
#[allow(unused)]
use std::{
cell::{Cell, RefCell, UnsafeCell},
collections::VecDeque,
marker::PhantomData,
mem::{ManuallyDrop, MaybeUninit},
ops::{Deref, DerefMut},
ptr::NonNull,
rc::Rc,
sync::{*, atomic::{*, Ordering::*}},
thread::{self, Thread},
};

补充材料,包括所有代码示例的完整版本,可在 https://marabos.nl/atomics/ 获得

你可以出于任何目的使用本书提供的所有示例代码。

下一章:Rust并发基础



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



总结一下过去三年的产品心路

在前几天公司的年度启动会上,周老师(副总裁)展示的PPT上有一页让我印象深刻,他展示了公司目前现有的8大主力产品在项目中的复用率,力石小知是唯一一款复用率100%的产品。当然,我知道这个100%是在一定时间段内来说的,如果时间拉长到三年半的区间,小知不可能做到100%的复用率,况且加上小知的知识运营、数据运营等工作,100%也就仅限于软件产品层面了。

虽然作为当事人,我会感觉到其中的筚路蓝缕,且SaaS道路一直未真正打开。不过这些都没关系,小知作为一个团队的努力成果,依然有很多值得和大家分享的地方,特别是在产品的0到1过程中的点点滴滴。

在开始分享前,先对复用率给出我的观点:产品复用率这事情,和产品经理的关系可能没想象中那么大,顶多只占1/4

好了,接下来我开始分享小知的三年产品成长心路,看完之后你应该能同意我的一些观点,并且看到力石科技在做产品上的一些方法。

方法1:产品-市场对角线

01 开始于普陀山

2019年夏天,当时我所在的大数据团队逐渐形成一个想法,我们需要有一款服务C(这里指游客)的产品,这样更利于我们对市场有充分感知,并且为我们的客户(旅游局/集团、景区、博物馆、乡村等)服务他们的客户(游客/观众)是一件很酷的事情。于是,我们就开始构思,并做了PPT,去普陀山拜访了客户。

说起来第一步踏出去是很顺利、很幸运的——客户非常接受我们的想法,对创新事物持拥抱态度,并且当场就表示,只要可以给普陀山的游客带来更好的服务,他们愿意当小白鼠。这给了我莫大的鼓舞,回杭州之后,我们就开始规划产品设计和研发。

02 一支很有活力的团队

当时大数据团队除了我之外,全部都是分析师,虽然他们里面最低学历都是硕士(包括牙医和叶子都是世界排名前50的大学的海归双硕士),但是不懂产品设计,也不懂工程型的编程语言(他们精通Python、R等更适合数据分析的语言)。而作为一个刚刚”想象“出来的”产品“,我们也不太可能动用公司的研发中心资源。但这样的团队是有很强的学习能力和创新能力的,于是,用水笔画在A4纸上的一张张原型就出来了。然后向公司申请了一个实习生名额——我的学弟,会写Java,然后再招聘了一位前端工程师(个人感觉他是我合作过最厉害的前端,后面也果然去了阿里等大厂)。就这样,过家家似的,产品在当年的9月26日在普陀山正式上线了。

然后,接下来就是我们自己不断地感受到这个新产品的”Low“,它还有太多我们想做却没完成的功能。客户那边在使用的过程中也不断给出意见,大部分我觉得都是合理的,我们应接不暇,当时我已经准备开始重操代码了。正在这时,和我一起工作多年的小明(也是国内著名开源作者)阴差阳错地加入了小知产研组,于是产品能力开始快速提升,而且更重要的是我们开始了底层架构地调整,目的是为了迎接这个产品不断变化的需求,因为这时候我们也不知道这个产品未来的形态是怎么样的,就像我们老板(他一直非常支持我们的孵化产品)当时说的,只有一个普陀山并不能证明这个产品是符合市场需求的。(以下是小明的开源作品,听说阿里、京东和美团都有人在用,因为他有一个交流群)

knife4j

03 开始走向市场

于是,摆在我面前的新问题就是获得更多用户的认可。在这件事情上,我首先做的事情是自己先去跑更多客户,这里也非常感谢一些现同事、老同事的帮助,介绍了大量机会。我和团队的销售(另外一个小明)在2019年的Q4跑了大概30多家景区,拜访了大量景区客户,收集了很多好的需求(包括部分客户痛点)和使用场景。于是,2020年疫情复工之后,我们在一边不断升级产品的同时,也直接或间接地有了颐和园、天台山、九华山、杏花村、西溪湿地等一波新客户。然后,非常重要的是我们开始了真正的产品方案PPT编写。虽然三年后回头看,当时的PPT是如此的”不堪入目“,但对于当时的我们来说,已经觉得”有点东西“了,特别是后来加入的小黄,让方案有很大提升。我们将方案推向解决方案部门,做讲解、培训,做产品演示,于是”征服“(其实是祈求他们认同)了一些解决方案工程师,包括泥叔、江海等同事。他们将力石小知的方案单独或者整合进他们的项目方案中,于是,在他们的推动下,在销售同事的努力下,我们的客户数增长很快(当然都是以项目的形式)。

04 客户成功组

一段时间之后,大家发现我们的很多客户其实是”静默“的,他们对于小知完全没有存在感,特别是那些在项目中带来的客户,这一点也一直在公司内部被诟病。在之前的一段时间,因为疫情,公司的组织架构也做了调整——我这边的大数据团队被并入到原运营中心,还并入了大量的产品和研发人员,组成了BOD中心,BOD的意思是建设+运营+数据。对我们最大的变化有两个:一是我的注意力被大量迁移到了项目和老游管佳产品(后面还会提到新游管佳);二是因为BOD中心的分管VP是运营方向的,深处运营中心,小知团队开始有了运营意识。这段时间大家都学会了和我们最好的几家客户打成一片,直接的好处就是不断收集到新的、真实的一线需求,而且客户对于小知这个产品也更加重视,真正使用起来了。我们这部分工作目前叫知识运营,其实我觉得后面可以改成客户成功组。

chats

好了,这部分我就说到这里了,我们来看看这个图,什么是产品-市场对角线,不需要去百度查这个名词,因为是我刚刚”发明“的。

产品-市场对角线

你会发现,我上面说的整个历程和这里是完全匹配的,从一个idea,到第一个愿意试错的客户,再到MVP(最小可用品),再到v1.0版本出现,进行渠道推广,再到后面建立运营和客户成功来收集客户反馈数据(加上游客的需求数据,以及后面又加入了埋点分析数据),不断迭代升级,整个产品发展逻辑已经形成了闭环。如果你在打造新的产品,也许可以参考。当然,我们的产品市场是面向ToB和ToG的,和ToC的逻辑会有一些不同,在ToC的领域,也许我说的这些,对于你们来说和吃饭一样平常。

产品-市场对角线对产品可以做到100%复用是有一定关系的,因为我们对市场需求的感知是更加敏感了,是主动在变,而不是被动在修。需要提一下的是:这时候我们还没有产品经理

后面还会讲到另外两三个方法,包括:

  • 创新的选择:如何去取舍洪水一样涌过来的需求?
  • 架构和需求的选择:架构和需求哪个更重要?
  • 组织的思考:组织就像一辆车……

后面会提到我们后面有产品经理了,而且还有一个新产品的诞生…


剩下的等到周末再写吧,年后回来真的好忙好忙!


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



ChatGPT可以为智慧文旅带来什么

最近只要你接触互联网,一定被ChatGPT疯狂刷屏了。确实ChatGPT作为AI领域的应用服务,据说出发布到1亿使用者只用了60天,而且还在以指数级的速度增长用户,给全世界在AI方向上带来了全新的想象空间。因为我一直在主导公司的AI产品“力石小知”,所以最近也被多位客户和同事问到ChatGPT的事情,那今天就来聊聊我自己的理解,ChatGPT可能给文旅行业带来什么影响。

下面我会从以下几项内容来讲述:

  1. ChatGPT是什么;
  2. 缩小到文旅行业,ChatGPT有无法解决的问题;
  3. ChatGPT会给文旅行业带来哪些改变,包括游客习惯、从业者思维和产品形态等;
  4. 聊聊给行业的启示。

一、ChatGPT是什么

其实大家上网搜一下,已经有太多ChatGPT的介绍了,我不太想复制黏贴,就用我自己的语言来简单介绍一下吧,尽量让所有朋友都能看得懂。

ChatGPT是OpenAI这个组织的一个产品,OpenAI的创始人叫山姆·阿尔特曼(Sam Altman),也是一个斯坦福辍学的天才,投资他的主要有哈佛辍学的比尔盖茨(微软)和特斯拉的马斯克等。今天我们不去管商业的东西,先来简单讲一下技术,特别是在ChatGPT相关的认知智能(听懂人类的自然说话的语言,并进行对话)的发展历程:

  • 人类最早去解决自然语言问题用的方法是语言学家的语法逻辑,当然,这个计划失败了,主要原因不是逻辑问题,而是这种方式无法借助计算机的能力,以至于无法大面积突破;
  • 后面采用的是统计学能力,狭义上可以理解为用计算机和统计学去分析已有的海量人类语言,然后通过其中的单次/词语出现的频次和先后顺序等来猜测这句话的意图,该方法在翻译、语音转文字等方面非常有效,但是对于自然语言的解析还是不够;
  • 随后就是基于机器学习(下面的图例主要讲机器学习)的各种升级,如深度学习和神经网络阶段,这里就有谷歌推出的Transformer和双向无监督预训练模型BERT,而ChatGPT的主要模型GPT也是基于Transformer的,GPT可以翻译为一种生成式的预训练Transformer模型,如果只从技术上来说,BERT应该优于GPT,特别是在GPT2出来之前。当然,现在的ChatGPT用的是GPT3.5。

下面我用一个大部分人可以看懂的例子来讲述一下这些人工智能的原理,当然,只是一个例子,所以屏蔽了大量的细节。

弹子球机

假设我们有一个弹子球机器,把各种不同(颜色、重量、尺寸)的球从顶部扔进去,球会和里面的这些柱子(杯子上方的这些圈圈)相碰撞,最终掉下来,落进最下面的杯子里。我们期望机器可以做到”红色的球最终掉进红色的杯子,蓝色球掉进蓝色杯子,依次类推“,我们可以做的事情是调整机器里面的柱子(假设这些柱子表面是不规则的,而且我们可以旋转这些柱子)。

如果我们一次次进行试验,然后不断去调整里面的柱子,是可以达到一个比较理想的状态的。需要注意的是,这里我们采用计算机来做试验,所以如果试验100次不够,那就1亿次,如果柱子的层数不够而引起达不到理想的状态,那么我们可以不断增加柱子,就像图里面,每个柱子其实又可以细化成无数的柱子。最终让红球落到红杯子,蓝球落到蓝杯子,…… 。这时候,这些所有的柱子(参数)的状态,就形成了一个模型,这个模型的作用就是按颜色分球。

当然,这还是属于有监督学习,后面从Transformer组件出来之后,衍生出BERT和GPT这两个模型,都逐渐摆脱了有监督学习,转变为无监督学习。BERT是将维基百科全部吃下去了,然后自己消化学习;而GPT模型GPT2开始吃下去的数据和知识就更多,以至于到GPT3.5,它的模型内部已经有1750亿个参数(一个参数你可以理解为一根柱子)。可以无监督学习得益于fine-tune(你理解为微调就好了),它是基于价值选择的许多函数组成的,包括softmax等梯度函数。好吧!后面的我也开始感觉很吃力了,至少已经不能深入浅出地讲出来让大家很容易就听懂了。

说回到ChatGPT,它就是基于GPT3.5大模型的一个人工智能对话应用,是一种终端实现,是可以让咱们这些普通人可以接触使用的。而这次之所以会闹的如此轩然大波,很重要的原因是它确实已经可以帮我们做一些事情了,特别是它非常像一个人在和你对话,而且掌握着无穷的知识的人。

现在让人最爽的应该写作,比如下图所示。

lanqiu

然后它还有很强的多轮对话能力,能够很好识别上下文的意思。我把它里面空着的日期、地点和我的名字等发给它,于是:

lanqiu2

还有就是写代码的能力也非常不错,当然,仅限于小白级别的程序员水平。更高层级的程序要做的事情更多是在做决策,而不是写代码,比如考虑代码的扩展性,或者应对客户后期会提出的一些新需求等,在代码上做一些架构思考和铺垫工作。

code

是不是觉得ChatGPT很牛逼啊,确实是非常牛逼的,有划时代意义!

但是它依然还有很多缺陷,下面会提到。

另外提一句,作为Transformer的创始公司,Google目前的大模型参数节点到了5400亿的级别,是OpenAI的3倍多。只是,OpenAI作为一家”在野党“,即使还有诸多不完美,依然受到热捧,而谷歌作为”执政党“,必须要拿出更完美的产品才能得到世人的满意,就这点来说,Google是吃了一个暗亏的。期待Google的Bard出来之后的表现,毕竟现在它还在搜索引擎领域面临着OpenAI加持之后的Bing搜索引擎(隶属于OpenAI最大的股东微软的产品)的威胁。

二、在文旅行业,ChatGPT还有哪些问题无法解决

ChatGPT的出现我个人认为积极的意义绝对更大,但是各行业的从业人员觉得划时代的狂欢(会被取代的从业者除外)已经到来了,那事实是还早着。

ChatGPT出来不久,我就注册了,并将它和小知做了一些对比,发现ChatGPT目前还有一些比较麻烦的事情。

首先是精准服务问题

目前的测试感受上来说,ChatGPT确实可以模拟人类的话语体系,但是在准确度不高。比如上图中关于”地体去颐和园的话怎么走“,它给出的答案中漏洞百出:

  1. ​ 西直门站没有10号线(正确的回答是4号线);
  2. ​ 10号线没有颐和园站(正确的回答是4号线坐到西苑站)。

如果这样的回复用在景区官方提供的服务上,那必然是造成投诉的。所以在精准服务上,ChatGPT现阶段还做的不够好,它更像是一个5岁的孩子,已经会学大人说话了,但是说的内容就是一本正经胡说八道。虽然我依然觉得它还是会成长的,成长到更加成熟的阶段,但是即使是大人,对于这些精准服务的内容也不一定就知道的。所以,在精准服务这一环上来说,知识库,特别是精准的知识库,依然是无法取代的存在。

其次是实时数据更新

在景区场景中,经常会遇到一些极端天气,比如城市里的冰雹,海岛的台风/大风,都会让景区管理部门紧急关停服务。

这些信息目前是无法实时维护到ChatGPT上的,就会对游客造成很多困扰,以致引起游客投诉。当然,据说最新的ChatGPT Plus(我还没注册)已经可以学习最新的新闻内容了。但是对于如此庞大的信息内容,学习也是需要一段时间的,比如7天,那对于紧急关停等通知内容是不能有效消化的。所以这里需要的是一些人工的维护,可以及时打上补丁。

目前ChatGPT对中国是不开放的,以后会不会开放目前无定论。如果国内自己的大模型出来了,那可能有机会开放开发者接口,让相应的客户有机会去临时修改各类服务信息。

最后是从服务中得到的问题分析

如果企业A在ChatGPT上开了一个服务账号,ChatGPT会不会帮助企业A分析该企业的用户咨询中发现的各类问题,比如服务中存在的盲区、咨询中洞察到的商机等,这些对于很多企业是非常急需的(目前小知的一些客户对该需求很旺盛)。

目前没有在任何信息渠道发现ChatGPT会不会有这样的服务,这里面核心点在于企业A购买了服务之后,对他和他的客户产生的数据是否使用权。

三、ChatGPT会给文旅行业带来哪些改变

虽然我们上一章提了一些目前它还不太适应文旅行业的事实,但是就像我前面也表达过的观点:ChatGPT给各行业带来更多的肯定是正向的东西,下面简单讲述一下我自己思考的ChatGPT会给文旅行业带来的影响。

首先是市场教育成本和用户习惯改变

力石小知的底层模型使用的是BERT,说起来和ChatGPT的底层模型GPT属于同宗同门,但是近三年的推广中,其实非常大的现实问题就是认可度。我们目前的优质客户里面,往往都有一个在技术层面上本身就比较”狂热“,或者对新事物比较有探索精神的负责人,也正是因为他们的力主推荐,小知才能在这些客户(景区)发展地很好。其他客户,更多是不知所措,或者可有可无的态。但是随着ChatGPT这波全球热潮,60天就获得1亿活跃用户,现在哪怕是以前不在意的一些景区客户,和我交流时也会聊上一句ChatGPT。所以,客户侧对于AI服务的认可度接下来会断崖式上升(断崖式不知道用的对不对)。

更可怕的是用户侧,在文旅行业也就是游客或者(博物馆的)参观者。他们同样会这一波ChatGPT热潮影响着,接下来他们对于AI服务产品的使用意愿也会断崖式上升。

越来越旺的市场需求对于产品的提升是最大的利好,相信不用一年时间,市场需求就会几十倍的增长,届时,如果你(景区、文博场馆)没有,那就会被认为Low。

其次是改变游客的使用方式

目前景区对游客提供的在线服务一般是聚焦在微信公众号或者微信小程序上的,当然,还有APP等。目前的服务提供方式仅有浏览的方式,而很多服务项是被隐藏的很深,或者需要游客比较细致才能完成自己所需的服务需求的。

下面来举一个例子,比较一下加入AI服务之后,会如何改变游客的使用方式。

我们以B景区为例,小程序中可选择的门票包括成人票、老年票(60周岁以上半价、70周岁以上免费)、半价票(身高1.2米以下或6周岁以下免票、1.2米-1.5米或6周岁-18周岁半票),实际的票务规则会更加详细。这时候,一个游客带着一个4岁的孩子,加上父母(分别是72岁和69岁),那买票的时候,是不是要把老年票、半价票的规则都好好研究一遍才能下单。

如果是用AI服务呢?直接语音咨询的方式就可以,如以下场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
游客:“我要买票”

---AI服务:”请问您需要购买几张票?“

游客:“我自己,加上一个4岁的孩子,还有我的父母”

---AI服务:“好的,可否再问一下,您的父母分别是什么年龄?”

游客:“爸爸72岁,妈妈69岁(或19556月)”

---AI服务:“感谢您的配合,根据您提供的购票人员情况,您一共需要买一张成人票,和一张半价票,根据票务规则,您的孩子和父亲可享受免票优惠,您本人需要购买一张成人票,您的母亲还不满70周岁,需要购买一张半价票。是否现在为您下单?”

游客:“好的”

最后,AI服务在界面上提供购票入口,可能包括实名身份证信息输入,电子票接收人的手机号码输入等,然后是一张成人票+一张半价票的总价支付界面,待游客支付,完成订单。

上面这个例子,我们用感性一点的方式去思考,至少游客不需要把进行心算,不需要去临时记忆景区B的各种票务规则,不用说“让我找个地方坐下来好好看看怎么买票”。

最后是服务产品的模式升级

从这次微软(还是在强调一下,它是OpenAI的最大股东)推出的集成ChatGPT的内测版Bing的截图可以看到,Bing除了默认的搜索引擎界面,还有一个类似对话方式的界面。也就是说,搜索引擎除了输入关键字之外,还可以像聊天一样使用了。

那么对于我们现在的绝大多数文旅行业的公众号、小程序和APP呢,后面会不会因为ChatGPT这样的AI能力而增加新的交互方式,比如像小知的交互。

从游客使用方式上来说,传统的浏览方式适合于游客还无法明确知道的目的时使用;而AI方式则更适合于游客有明确目的,或者目的比较复杂的情况下使用。

就像网上有人说的“现在所有的网站和APP都会被ChatGPT(或类似的AI服务)重新再做一遍!”,我觉得在很近的未来,这种情况就会发生。

四、总结

这次ChatGPT改变的不仅仅是文旅行业,更可能是改变全人类的生活习惯。就像原来在杭州给北京传个信,驿马要跑近半个月;就像以前2007年前,我们大部分人想不到手机会如此巨大的改变我们的生活。

作为行业从业者,唯有积极学习,然后努力应用,为行业做出贡献,为游客带来游玩体验上的大幅提升。

最后,力石小知团队目前已经在原有的BERT基础上,又进行了GPT2的内部技术测试,这玩意儿老费钱了,特别是GPU的投入,多多益善。未来,在这一类产品的研发上,拼的主要是“人才”、“资金(硬件投入)”和“业务场景(特别是数据)”。幸运的是,目前力石小知在这三方面都已经有一定的积累。

文旅行业的AI研究和应用,依然任重道远。






关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



Effective Java 3rd(Effective Java 第三版中文翻译) (31-37)

本文是根据《Effective Java 3rd》英文版翻译的,仅供自己学习用!

31. 使用限定通配符来增加API的灵活性

如条目 28所述,参数化类型是不变的。换句话说,对于任何两个不同类型的Type1TypeList <Type1>既不是List <Type2>子类型也不是其父类型。尽管List <String>不是List <Object>的子类型是违反直觉的,但它确实是有道理的。 可以将任何对象放入List <Object>中,但是只能将字符串放入List <String>中。 由于List <String>不能做List <Object>所能做的所有事情,所以它不是一个子类型(条目 10 中的里氏替代原则)。

相对于提供的不可变的类型,有时你需要比此更多的灵活性。 考虑条目 29中的Stack类。下面是它的公共API:

1
2
3
4
5
6
7
8
9
10
11
public class Stack<E> {

public Stack();

public void push(E e);

public E pop();

public boolean isEmpty();

}

假设我们想要添加一个方法来获取一系列元素,并将它们全部推送到栈上。 以下是第一种尝试:

1
2
3
4
5
6
// pushAll method without wildcard type - deficient!

public void pushAll(Iterable<E> src) {
for (E e : src)
push(e);
}

这种方法可以干净地编译,但不完全令人满意。 如果可遍历的src元素类型与栈的元素类型完全匹配,那么它工作正常。 但是,假设有一个Stack <Number>,并调用push(intVal),其中intVal的类型是Integer。 这是因为IntegerNumber的子类型。 从逻辑上看,这似乎也应该起作用:

1
2
3
4
5
Stack<Number> numberStack = new Stack<>();

Iterable<Integer> integers = ... ;

numberStack.pushAll(integers);

但是,如果你尝试了,会得到这个错误消息,因为参数化类型是不变的:

1
2
3
4
5
6
7
StackTest.java:7: error: incompatible types: Iterable<Integer>

cannot be converted to Iterable<Number>

numberStack.pushAll(integers);

^

幸运的是,有对应的解决方法。 该语言提供了一种特殊的参数化类型来调用一个限定通配符类型来处理这种情况。 pushAll的输入参数的类型不应该是“E的Iterable接口”,而应该是“E的某个子类型的Iterable接口”,并且有一个通配符类型,这意味着:Iterable <? extends E>。 (关键字extends的使用有点误导:回忆条目 29中,子类型被定义为每个类型都是它自己的子类型,即使它本身没有继承。)让我们修改pushAll来使用这个类型:

1
2
3
4
5
6
// Wildcard type for a parameter that serves as an E producer

public void pushAll(Iterable<? extends E> src) {
for (E e : src)
push(e);
}

有了这个改变,Stack类不仅可以干净地编译,而且客户端代码也不会用原始的pushAll声明编译。 因为Stack和它的客户端干净地编译,你知道一切都是类型安全的。

现在假设你想写一个popAll方法,与pushAll方法相对应。 popAll方法从栈中弹出每个元素并将元素添加到给定的集合中。 以下是第一次尝试编写popAll方法的过程:

1
2
3
4
5
6
7
8
9
// popAll method without wildcard type - deficient!

public void popAll(Collection<E> dst) {

while (!isEmpty())

dst.add(pop());

}

同样,如果目标集合的元素类型与栈的元素类型完全匹配,则干净编译并且工作正常。 但是,这又不完全令人满意。 假设你有一个Stack <Number>Object类型的变量。 如果从栈中弹出一个元素并将其存储在该变量中,它将编译并运行而不会出错。 所以你也不能这样做吗?

1
2
3
4
5
Stack<Number> numberStack = new Stack<Number>();

Collection<Object> objects = ... ;

numberStack.popAll(objects);

如果尝试将此客户端代码与之前显示的popAll版本进行编译,则会得到与我们的第一版pushAll非常类似的错误:Collection <Object>不是Collection <Number>的子类型。 通配符类型再一次提供了一条出路。 popAll的输入参数的类型不应该是“E的集合”,而应该是“E的某个父类型的集合”(其中父类型被定义为E是它自己的父类型[JLS,4.10])。 再次,有一个通配符类型,正是这个意思:Collection <? super E>。 让我们修改popAll来使用它:

1
2
3
4
5
6
7
8
9
// Wildcard type for parameter that serves as an E consumer

public void popAll(Collection<? super E> dst) {

while (!isEmpty())

dst.add(pop());

}

通过这个改动,Stack类和客户端代码都可以干净地编译。

这个结论很清楚。 为了获得最大的灵活性,对代表生产者或消费者的输入参数使用通配符类型。 如果一个输入参数既是一个生产者又是一个消费者,那么通配符类型对你没有好处:你需要一个精确的类型匹配,这就是没有任何通配符的情况。

这里有一个助记符来帮助你记住使用哪种通配符类型: PECS代表: producer-extends,consumer-super。

换句话说,如果一个参数化类型代表一个T生产者,使用<? extends T>;如果它代表T消费者,则使用<? super T>。 在我们的Stack示例中,pushAll方法的src参数生成栈使用的E实例,因此src的合适类型为Iterable<? extends E>popAll方法的dst参数消费Stack中的E实例,因此dst的合适类型是Collection <? super E>。 PECS助记符抓住了使用通配符类型的基本原则。 Naftalin和Wadler称之为获取和放置原则( Get and Put Principle )[Naftalin07,2.4]。

记住这个助记符之后,让我们来看看本章中以前项目的一些方法和构造方法声明。 条目 28中的Chooser类构造方法有这样的声明:

1
public Chooser(Collection<T> choices)

这个构造方法只使用集合选择来生产类型T的值(并将它们存储起来以备后用),所以它的声明应该使用一个extends T的通配符类型。下面是得到的构造方法声明:

1
2
3
// Wildcard type for parameter that serves as an T producer

public Chooser(Collection<? extends T> choices)

这种改变在实践中会有什么不同吗? 是的,会有不同。 假你有一个List <Integer>,并且想把它传递给Chooser<Number>的构造方法。 这不会与原始声明一起编译,但是它只会将限定通配符类型添加到声明中。

现在看看条目 30中的union方法。下是声明:

1
public static <E> Set<E> union(Set<E> s1, Set<E> s2)

两个参数s1s2都是E的生产者,所以PECS助记符告诉我们该声明应该如下:

1
public static <E> Set<E> union(Set<? extends E> s1,  Set<? extends E> s2)

请注意,返回类型仍然是Set <E>。 不要使用限定通配符类型作为返回类型。除了会为用户提供额外的灵活性,还强制他们在客户端代码中使用通配符类型。 通过修改后的声明,此代码将清晰地编译:

1
2
3
4
5
Set<Integer>  integers =  Set.of(1, 3, 5);

Set<Double> doubles = Set.of(2.0, 4.0, 6.0);

Set<Number> numbers = union(integers, doubles);

如果使用得当,类的用户几乎不会看到通配符类型。 他们使方法接受他们应该接受的参数,拒绝他们应该拒绝的参数。 如果一个类的用户必须考虑通配符类型,那么它的API可能有问题。

在Java 8之前,类型推断规则不够聪明,无法处理先前的代码片段,这要求编译器使用上下文指定的返回类型(或目标类型)来推断E的类型。union方法调用的目标类型如前所示是Set <Number>。 如果尝试在早期版本的Java中编译片段(以及适合的Set.of工厂替代版本),将会看到如此长的错综复杂的错误消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Union.java:14: error: incompatible types

Set<Number> numbers = union(integers, doubles);

^

required: Set<Number>

found: Set<INT#1>

where INT#1,INT#2 are intersection types:

INT#1 extends Number,Comparable<? extends INT#2>

INT#2 extends Number,Comparable<?>

幸运的是有办法来处理这种错误。 如果编译器不能推断出正确的类型,你可以随时告诉它使用什么类型的显式类型参数[JLS,15.12]。 甚至在Java 8中引入目标类型之前,这不是你必须经常做的事情,这很好,因为显式类型参数不是很漂亮。 通过添加显式类型参数,如下所示,代码片段在Java 8之前的版本中进行了干净编译:

1
2
3
// Explicit type parameter - required prior to Java 8

Set<Number> numbers = Union.<Number>union(integers, doubles);

接下来让我们把注意力转向条目 30中的max方法。这里是原始声明:

1
public static <T extends Comparable<T>> T max(List<T> list)

以下是使用通配符类型的修改后的声明:

1
public static <T extends Comparable<? super T>> T max(List<? extends T> list)

为了从原来到修改后的声明,我们两次应用了PECS。首先直接的应用是参数列表。 它生成T实例,所以将类型从List <T>更改为List<? extends T>。 棘手的应用是类型参数T。这是我们第一次看到通配符应用于类型参数。 最初,T被指定为继承Comparable <T>,但ComparableT消费T实例(并生成指示顺序关系的整数)。 因此,参数化类型Comparable <T>被替换为限定通配符类型Comparable<? super T>Comparable实例总是消费者,所以通常应该使用Comparable<? super T>优于ComparableComparator也是如此。因此,通常应该使用Comparator<? super T>优于Comparator

修改后的max声明可能是本书中最复杂的方法声明。 增加的复杂性是否真的起作用了吗? 同样,它的确如此。 这是一个列表的简单例子,它被原始声明排除,但在被修改后的版本里是允许的:

1
List<ScheduledFuture<?>> scheduledFutures = ... ;

无法将原始方法声明应用于此列表的原因是ScheduledFuture不实现Comparable <ScheduledFuture>。 相反,它是Delayed的子接口,它继承了Comparable <Delayed>。 换句话说,一个ScheduledFuture实例不仅仅和其他的ScheduledFuture实例相比较: 它可以与任何Delayed实例比较,并且足以导致原始的声明拒绝它。 更普遍地说,通配符要求来支持没有直接实现Comparable(或Comparator)的类型,但继承了一个类型。

还有一个关于通配符相关的话题。 类型参数和通配符之间具有双重性,许多方法可以用一个或另一个声明。 例如,下面是两个可能的声明,用于交换列表中两个索引项目的静态方法。 第一个使用无限制类型参数(条目 30),第二个使用无限制通配符:

1
2
3
4
5
// Two possible declarations for the swap method

public static <E> void swap(List<E> list, int i, int j);

public static void swap(List<?> list, int i, int j);

这两个声明中的哪一个更可取,为什么? 在公共API中,第二个更好,因为它更简单。 你传入一个列表(任何列表),该方法交换索引的元素。 没有类型参数需要担心。 通常,如果类型参数在方法声明中只出现一次,请将其替换为通配符。 如果它是一个无限制的类型参数,请将其替换为无限制的通配符; 如果它是一个限定类型参数,则用限定通配符替换它。

第二个swap方法声明有一个问题。 这个简单的实现不会编译:

1
2
3
public static void swap(List<?> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}

试图编译它会产生这个不太有用的错误信息:

1
2
3
4
5
6
7
8
9
10
11
Swap.java:5: error: incompatible types: Object cannot be

converted to CAP#1

list.set(i, list.set(j, list.get(i)));

^

where CAP#1 is a fresh type-variable:

CAP#1 extends Object from capture of ?

看起来我们不能把一个元素放回到我们刚刚拿出来的列表中。 问题是列表的类型是List <?>,并且不能将除null外的任何值放入List <?>中。 幸运的是,有一种方法可以在不使用不安全的转换或原始类型的情况下实现此方法。 这个想法是写一个私有辅助方法来捕捉通配符类型。 辅助方法必须是泛型方法才能捕获类型。 以下是它的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void swap(List<?> list, int i, int j) {

swapHelper(list, i, j);

}

// Private helper method for wildcard capture

private static <E> void swapHelper(List<E> list, int i, int j) {

list.set(i, list.set(j, list.get(i)));

}

swapHelper方法知道该列表是一个List <E>。 因此,它知道从这个列表中获得的任何值都是E类型,并且可以安全地将任何类型的E值放入列表中。 这个稍微复杂的swap的实现可以干净地编译。 它允许我们导出基于通配符的漂亮声明,同时利用内部更复杂的泛型方法。 swap方法的客户端不需要面对更复杂的swapHelper声明,但他们从中受益。 辅助方法具有我们认为对公共方法来说过于复杂的签名。

总之,在你的API中使用通配符类型,虽然棘手,但使得API更加灵活。 如果编写一个将被广泛使用的类库,正确使用通配符类型应该被认为是强制性的。 记住基本规则: producer-extends, consumer-super(PECS)。 还要记住,所有ComparableComparator都是消费者。

32. 合理地结合泛型和可变参数

在Java 5中,可变参数方法(条目 53)和泛型都被添加到平台中,所以你可能希望它们能够正常交互; 可悲的是,他们并没有。 可变参数的目的是允许客户端将一个可变数量的参数传递给一个方法,但这是一个脆弱的抽象( leaky abstraction):当你调用一个可变参数方法时,会创建一个数组来保存可变参数;那个应该是实现细节的数组是可见的。 因此,当可变参数具有泛型或参数化类型时,会导致编译器警告混淆。

回顾条目 28,非具体化( non-reifiable)的类型是其运行时表示比其编译时表示具有更少信息的类型,并且几乎所有泛型和参数化类型都是不可具体化的。 如果某个方法声明其可变参数为非具体化的类型,则编译器将在该声明上生成警告。 如果在推断类型不可确定的可变参数参数上调用该方法,那么编译器也会在调用中生成警告。 警告看起来像这样:

1
2
warning: [unchecked] Possible heap pollution from
parameterized vararg type List<String>

当参数化类型的变量引用不属于该类型的对象时会发生堆污染(Heap pollution)[JLS,4.12.2]。 它会导致编译器的自动生成的强制转换失败,违反了泛型类型系统的基本保证。

例如,请考虑以下方法,该方法是第127页上的代码片段的一个不太明显的变体:

1
2
3
4
5
6
7
// Mixing generics and varargs can violate type safety!
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList; // Heap pollution
String s = stringLists[0].get(0); // ClassCastException
}

此方法没有可见的强制转换,但在调用一个或多个参数时抛出ClassCastException异常。 它的最后一行有一个由编译器生成的隐形转换。 这种转换失败,表明类型安全性已经被破坏,并且将值保存在泛型可变参数数组参数中是不安全的

这个例子引发了一个有趣的问题:为什么声明一个带有泛型可变参数的方法是合法的,当明确创建一个泛型数组是非法的时候呢? 换句话说,为什么前面显示的方法只生成一个警告,而127页上的代码片段会生成一个错误? 答案是,具有泛型或参数化类型的可变参数参数的方法在实践中可能非常有用,因此语言设计人员选择忍受这种不一致。 事实上,Java类库导出了几个这样的方法,包括Arrays.asList(T... a)Collections.addAll(Collection<? super T> c, T... elements)EnumSet.of(E first, E... rest)。 与前面显示的危险方法不同,这些类库方法是类型安全的。

在Java 7中,SafeVarargs注解已添加到平台,以允许具有泛型可变参数的方法的作者自动禁止客户端警告。 实质上,SafeVarargs注解构成了作者对类型安全的方法的承诺。 为了交换这个承诺,编译器同意不要警告用户调用可能不安全的方法。

除非它实际上是安全的,否则注意不要使用@SafeVarargs注解标注一个方法。 那么需要做些什么来确保这一点呢? 回想一下,调用方法时会创建一个泛型数组,以容纳可变参数。 如果方法没有在数组中存储任何东西(它会覆盖参数)并且不允许对数组的引用进行转义(这会使不受信任的代码访问数组),那么它是安全的。 换句话说,如果可变参数数组仅用于从调用者向方法传递可变数量的参数——毕竟这是可变参数的目的——那么该方法是安全的。

值得注意的是,你可以违反类型安全性,即使不会在可变参数数组中存储任何内容。 考虑下面的泛型可变参数方法,它返回一个包含参数的数组。 乍一看,它可能看起来像一个方便的小工具:

1
2
3
4
// UNSAFE - Exposes a reference to its generic parameter array!
static <T> T[] toArray(T... args) {
return args;
}

这个方法只是返回它的可变参数数组。 该方法可能看起来并不危险,但它是! 该数组的类型由传递给方法的参数的编译时类型决定,编译器可能没有足够的信息来做出正确的判断。 由于此方法返回其可变参数数组,它可以将堆污染传播到调用栈上。

为了具体说明,请考虑下面的泛型方法,它接受三个类型T的参数,并返回一个包含两个参数的数组,随机选择:

1
2
3
4
5
6
7
8
static <T> T[] pickTwo(T a, T b, T c) {
switch(ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(a, b);
case 1: return toArray(a, c);
case 2: return toArray(b, c);
}
throw new AssertionError(); // Can't get here
}

这个方法本身不是危险的,除了调用具有泛型可变参数的toArray方法之外,不会产生警告。

编译此方法时,编译器会生成代码以创建一个将两个T实例传递给toArray的可变参数数组。 这段代码分配了一个Object []类型的数组,它是保证保存这些实例的最具体的类型,而不管在调用位置传递给pickTwo的对象是什么类型。 toArray方法只是简单地将这个数组返回给pickTwo,然后pickTwo将它返回给调用者,所以pickTwo总是返回一个Object []类型的数组。

现在考虑这个测试pickTwmain方法:

1
2
3
public static void main(String[] args) {
String[] attributes = pickTwo("Good", "Fast", "Cheap");
}

这种方法没有任何问题,因此它编译时不会产生任何警告。 但是当运行它时,抛出一个ClassCastException异常,尽管不包含可见的转换。 你没有看到的是,编译器已经生成了一个隐藏的强制转换为由pickTwo返回的值的String []类型,以便它可以存储在属性中。 转换失败,因为Object []不是String []的子类型。 这种故障相当令人不安,因为它从实际导致堆污染(toArray)的方法中移除了两个级别,并且在实际参数存储在其中之后,可变参数数组未被修改。

这个例子是为了让人们认识到给另一个方法访问一个泛型的可变参数数组是不安全的,除了两个例外:将数组传递给另一个可变参数方法是安全的,这个方法是用@SafeVarargs正确标注的, 将数组传递给一个非可变参数的方法是安全的,该方法仅计算数组内容的一些方法。

这里是安全使用泛型可变参数的典型示例。 此方法将任意数量的列表作为参数,并按顺序返回包含所有输入列表元素的单个列表。 由于该方法使用@SafeVarargs进行标注,因此在声明或其调用站位置上不会生成任何警告:

1
2
3
4
5
6
7
8
// Safe method with a generic varargs parameter
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists)
result.addAll(list);
return result;
}

决定何时使用SafeVarargs注解的规则很简单:在每种方法上使用@SafeVarargs,并使用泛型或参数化类型的可变参数,这样用户就不会因不必要的和令人困惑的编译器警告而担忧。 这意味着你不应该写危险或者toArray等不安全的可变参数方法。 每次编译器警告你可能会受到来自你控制的方法中泛型可变参数的堆污染时,请检查该方法是否安全。 提醒一下,在下列情况下,泛型可变参数方法是安全的: 1.它不会在可变参数数组中存储任何东西

2.它不会使数组(或克隆)对不可信代码可见。 如果违反这些禁令中的任何一项,请修复。

请注意,SafeVarargs注解只对不能被重写的方法是合法的,因为不可能保证每个可能的重写方法都是安全的。 在Java 8中,注解仅在静态方法和final实例方法上合法; 在Java 9中,它在私有实例方法中也变为合法。

使用SafeVarargs注解的替代方法是采用条目 28的建议,并用List参数替换可变参数(这是一个变相的数组)。 下面是应用于我们的flatten方法时,这种方法的样子。 请注意,只有参数声明被更改了:

1
2
3
4
5
6
7
// List as a typesafe alternative to a generic varargs parameter
static <T> List<T> flatten(List<List<? extends T>> lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists)
result.addAll(list);
return result;
}

然后可以将此方法与静态工厂方法List.of结合使用,以允许可变数量的参数。 请注意,这种方法依赖于List.of声明使用@SafeVarargs注解: audience = flatten(List.of(friends, romans, countrymen));

这种方法的优点是编译器可以证明这种方法是类型安全的。 不必使用SafeVarargs注解来证明其安全性,也不用担心在确定安全性时可能会犯错。 主要缺点是客户端代码有点冗长,运行可能会慢一些。

这个技巧也可以用在不可能写一个安全的可变参数方法的情况下,就像第147页的toArray方法那样。它的列表模拟是List.of方法,所以我们甚至不必编写它; Java类库作者已经为我们完成了这项工作。 pickTwo方法然后变成这样:

1
2
3
4
5
6
7
8
static <T> List<T> pickTwo(T a, T b, T c) {
switch(rnd.nextInt(3)) {
case 0: return List.of(a, b);
case 1: return List.of(a, c);
case 2: return List.of(b, c);
}
throw new AssertionError();
}

main方变成这样:

1
2
3
public static void main(String[] args) {
List<String> attributes = pickTwo("Good", "Fast", "Cheap");
}

生成的代码是类型安全的,因为它只使用泛型,不是数组。

总而言之,可变参数和泛型不能很好地交互,因为可变参数机制是在数组上面构建的脆弱的抽象,并且数组具有与泛型不同的类型规则。 虽然泛型可变参数不是类型安全的,但它们是合法的。 如果选择使用泛型(或参数化)可变参数编写方法,请首先确保该方法是类型安全的,然后使用@SafeVarargs注解对其进行标注,以免造成使用不愉快。

33. 优先考虑类型安全的异构容器

泛型的常见用法包括集合,如Set <E>Map <K,V>和单个元素容器,如ThreadLocal <T>AtomicReference <T>。 在所有这些用途中,它都是参数化的容器。 这限制了每个容器只能有固定数量的类型参数。 通常这正是你想要的。 一个Set有单一的类型参数,表示它的元素类型; 一个Map有两个,代表它的键和值的类型;等等。

然而有时候,你需要更多的灵活性。 例如,数据库一行记录可以具有任意多列,并且能够以类型安全的方式访问它们是很好的。 幸运的是,有一个简单的方法可以达到这个效果。 这个想法是参数化键(key)而不是容器。 然后将参数化的键提交给容器以插入或检索值。 泛型类型系统用于保证值的类型与其键一致。

作为这种方法的一个简单示例,请考虑一个Favorites类,它允许其客户端保存和检索任意多种类型的favorite实例。 该类型的Class对象将扮演参数化键的一部分。其原因是这Class类是泛型的。 类的类型从字面上来说不是简单的Class,而是Class <T>。 例如,String.class的类型为Class <String>Integer.class的类型为Class <Integer>。 当在方法中传递字面类传递编译时和运行时类型信息时,它被称为类型令牌(type token)[Bracha04]。

Favorites类的API很简单。 它看起来就像一个简单Map类,除了该键是参数化的以外。 客户端在设置和获取favorites实例时呈现一个Class对象。 这里是API:

1
2
3
4
5
// Typesafe heterogeneous container pattern - API
public class Favorites {
public <T> void putFavorite(Class<T> type, T instance);
public <T> T getFavorite(Class<T> type);
}

下面是一个演示Favorites类,保存,检索和打印喜欢的StringIntegerClass实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Typesafe heterogeneous container pattern - client

public static void main(String[] args) {

Favorites f = new Favorites();

f.putFavorite(String.class, "Java");

f.putFavorite(Integer.class, 0xcafebabe);

f.putFavorite(Class.class, Favorites.class);

String favoriteString = f.getFavorite(String.class);

int favoriteInteger = f.getFavorite(Integer.class);

Class<?> favoriteClass = f.getFavorite(Class.class);

System.out.printf("%s %x %s%n", favoriteString,

favoriteInteger, favoriteClass.getName());
}

正如你所期望的,这个程序打印Java cafebabe Favorites。 请注意,顺便说一下,Java的printf方法与C语言的不同之处在于,应该使用%n,而在C中使用\n%n生成适用的特定于平台的行分隔符,该分隔符在很多但不是所有平台上都是\n

Favorites实例是类型安全的:当你请求一个字符串时它永远不会返回一个整数。 它也是异构的:与普通Map不同,所有的键都是不同的类型。 因此,我们将Favorites称为类型安全异构容器(typesafe heterogeneous container.)。

Favorites的实现非常小巧。 这是完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
// Typesafe heterogeneous container pattern - implementation
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();

public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}

public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
}

这里有一些微妙的事情发生。 每个Favorites实例都由一个名为favorites私有的Map<Class<?>, Object>来支持。 你可能认为无法将任何内容放入此Map中,因为这是无限定的通配符类型,但事实恰恰相反。 需要注意的是通配符类型是嵌套的:它不是通配符类型的Map类型,而是键的类型。 这意味着每个键都可以有不同的参数化类型:一个可以是Class <String>,下一个Class <Integer>等等。 这就是异构的由来。

接下来要注意的是,favorites的Map的值类型只是Object。 换句话说,Map不保证键和值之间的类型关系,即每个值都是由其键表示的类型。 事实上,Java的类型系统并不足以表达这一点。 但是我们知道这是真的,并在检索一个favorite时利用了这点。

putFavorite实现很简单:只需将给定的Class对象映射到给定的favorites的实例即可。 如上所述,这丢弃了键和值之间的“类型联系(type linkage)”;无法知道这个值是不是键的一个实例。 但没关系,因为getFavorites方法可以并且确实重新建立这种关联。

getFavorite的实现比putFavorite更复杂。 首先,它从favorites Map中获取与给定Class对象相对应的值。 这是返回的正确对象引用,但它具有错误的编译时类型:它是Object(favorites map的值类型),我们需要返回类型T。因此,getFavorite实现动态地将对象引用转换为Class对象表示的类型,使用Class的cast方法。

cast方法是Java的cast操作符的动态模拟。它只是检查它的参数是否由Class对象表示的类型的实例。如果是,它返回参数;否则会抛出ClassCastException异常。我们知道,假设客户端代码能够干净地编译,getFavorite中的强制转换不会抛出ClassCastException异常。 也就是说,favorites map中的值始终与其键的类型相匹配。

那么这个cast方法为我们做了什么,因为它只是返回它的参数? cast的签名充分利用了Class类是泛型的事实。 它的返回类型是Class对象的类型参数:

1
2
3
public class Class<T> {
T cast(Object obj);
}

这正是getFavorite方法所需要的。 这正是确保Favorites类型安全,而不用求助一个未经检查的强制转换的T类型。

Favorites类有两个限制值得注意。 首先,恶意客户可以通过使用原始形式的Class对象,轻松破坏Favorites实例的类型安全。 但生成的客户端代码在编译时会生成未经检查的警告。 这与正常的集合实现(如HashSet和HashMap)没有什么不同。 通过使用原始类型HashSet(条目 26),可以轻松地将字符串放入HashSet <Integer>中。 也就是说,如果你愿意为此付出一点代价,就可以拥有运行时类型安全性。 确保Favorites永远不违反类型不变的方法是,使putFavorite方法检查该实例是否由type表示类型的实例,并且我们已经知道如何执行此操作。只需使用动态转换:

1
2
3
4
// Achieving runtime type safety with a dynamic cast
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(type, type.cast(instance));
}

java.util.Collections中有一些集合包装类,可以发挥相同的诀窍。 它们被称为checkedSetcheckedListcheckedMap等等。 他们的静态工厂除了一个集合(或Map)之外还有一个Class对象(或两个)。 静态工厂是泛型方法,确保Class对象和集合的编译时类型匹配。 包装类为它们包装的集合添加了具体化。 例如,如果有人试图将Coin放入你的Collection <Stamp>中,则包装类在运行时会抛出ClassCastException。 这些包装类对于追踪在混合了泛型和原始类型的应用程序中添加不正确类型的元素到集合的客户端代码很有用。

Favorites类的第二个限制是它不能用于不可具体化的(non-reifiable)类型(条目 28)。 换句话说,你可以保存你最喜欢的StringString [],但不能保存List <String>。 如果你尝试保存你最喜欢的List <String>,程序将不能编译。 原因是无法获取List <String>的Class对象。 List <String> .class是语法错误,也是一件好事。 List <String>List <Integer>共享一个Class对象,即List.class。 如果“字面类型(type literals)”List <String> .classList <Integer> .class合法并返回相同的对象引用,那么它会对Favorites对象的内部造成严重破坏。 对于这种限制,没有完全令人满意的解决方法。

Favorites使用的类型令牌( type tokens)是无限制的:getFavoriteputFavorite接受任何Class对象。 有时你可能需要限制可传递给方法的类型。 这可以通过一个有限定的类型令牌来实现,该令牌只是一个类型令牌,它使用限定的类型参数(条目 30)或限定的通配符(条目 31)来放置可以表示的类型的边界。

注解API(条目 39)广泛使用限定类型的令牌。 例如,以下是在运行时读取注解的方法。 此方法来自AnnotatedElement接口,该接口由表示类,方法,属性和其他程序元素的反射类型实现:

1
2
public <T extends Annotation>
T getAnnotation(Class<T> annotationType);

参数annotationType是表示注解类型的限定类型令牌。 该方法返回该类型的元素的注解(如果它有一个);如果没有,则返回null。 本质上,注解元素是一个类型安全的异构容器,其键是注解类型。

假设有一个Class <?>类型的对象,并且想要将它传递给需要限定类型令牌(如getAnnotation)的方法。 可以将对象转换为Class<? extends Annotation>,但是这个转换没有被检查,所以它会产生一个编译时警告(条目 27)。 幸运的是,Class类提供了一种安全(动态)执行这种类型转换的实例方法。 该方法被称为asSubclass,并且它转换所调用的Class对象来表示由其参数表示的类的子类。 如果转换成功,该方法返回它的参数;如果失败,则抛出ClassCastException异常。

以下是如何使用asSubclass方法在编译时读取类型未知的注解。 此方法编译时没有错误或警告:

1
2
3
4
5
6
7
8
9
10
11
12
// Use of asSubclass to safely cast to a bounded type token
static Annotation getAnnotation(AnnotatedElement element,
String annotationTypeName) {
Class<?> annotationType = null; // Unbounded type token
try {
annotationType = Class.forName(annotationTypeName);
} catch (Exception ex) {
throw new IllegalArgumentException(ex);
}
return element.getAnnotation(
annotationType.asSubclass(Annotation.class));
}

总之,泛型API的通常用法(以集合API为例)限制了每个容器的固定数量的类型参数。 你可以通过将类型参数放在键上而不是容器上来解决此限制。 可以使用Class对象作为此类型安全异构容器的键。 以这种方式使用的Class对象称为类型令牌。 也可以使用自定义键类型。 例如,可以有一个表示数据库行(容器)的DatabaseRow类型和一个泛型类型Column <T>作为其键。

34. 使用枚举类型替代整型常量

枚举是其合法值由一组固定的常量组成的一种类型,例如一年中的季节,太阳系中的行星或一副扑克牌中的套装。 在将枚举类型添加到该语言之前,表示枚举类型的常见模式是声明一组名为int的常量,每个类型的成员都有一个常量:

1
2
3
4
5
6
7
// The int enum pattern - severely deficient!
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;

这种被称为int枚举模式的技术有许多缺点。 它没有提供类型安全的方式,也没有提供任何表达力。 如果你将一个Apple传递给一个需要Orange的方法,那么编译器不会出现警告,还会用==运算符比较Apple与Orange,或者更糟糕的是:

1
2
// Tasty citrus flavored applesauce!
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

请注意,每个Apple常量的名称前缀为APPLE_,每个Orange常量的名称前缀为ORANGE_。 这是因为Java不为int枚举组提供名称空间。 当两个int枚举组具有相同的命名常量时,前缀可以防止名称冲突,例如在ELEMENT_MERCURYPLANET_MERCURY之间。

使用int枚举的程序很脆弱。 因为int枚举是编译时常量[JLS,4.12.4],所以它们的int值被编译到使用它们的客户端中[JLS,13.1]。 如果与int枚举关联的值发生更改,则必须重新编译其客户端。 如果没有,客户仍然会运行,但他们的行为将是不正确的。

没有简单的方法将int枚举常量转换为可打印的字符串。 如果你打印这样一个常量或者从调试器中显示出来,你看到的只是一个数字,这不是很有用。 没有可靠的方法来迭代组中的所有int枚举常量,甚至无法获得int枚举组的大小。

你可能会遇到这种模式的变体,其中使用了字符串常量来代替int常量。 这种称为字符串枚举模式的变体更不理想。 尽管它为常量提供了可打印的字符串,但它可以导致初级用户将字符串常量硬编码为客户端代码,而不是使用属性名称。 如果这种硬编码的字符串常量包含书写错误,它将在编译时逃脱检测并导致运行时出现错误。 此外,它可能会导致性能问题,因为它依赖于字符串比较。

幸运的是,Java提供了一种避免int和String枚举模式的所有缺点的替代方法,并提供了许多额外的好处。 它是枚举类型[JLS,8.9]。 以下是它最简单的形式:

1
2
public enum Apple  { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }

从表面上看,这些枚举类型可能看起来与其他语言类似,比如C,C ++和C#,但事实并非如此。 Java的枚举类型是完整的类,比其他语言中的其他语言更强大,其枚举本质本上是int值。

Java枚举类型背后的基本思想很简单:它们是通过公共静态final属性为每个枚举常量导出一个实例的类。 由于没有可访问的构造方法,枚举类型实际上是final的。 由于客户既不能创建枚举类型的实例也不能继承它,除了声明的枚举常量外,不能有任何实例。 换句话说,枚举类型是实例控制的(第6页)。 它们是单例(条目 3)的泛型化,基本上是单元素的枚举。

枚举提供了编译时类型的安全性。 如果声明一个参数为Apple类型,则可以保证传递给该参数的任何非空对象引用是三个有效Apple值中的一个。 尝试传递错误类型的值将导致编译时错误,因为会尝试将一个枚举类型的表达式分配给另一个类型的变量,或者使用==运算符来比较不同枚举类型的值。

具有相同名称常量的枚举类型可以和平共存,因为每种类型都有其自己的名称空间。 可以在枚举类型中添加或重新排序常量,而无需重新编译其客户端,因为导出常量的属性在枚举类型与其客户端之间提供了一层隔离:常量值不会编译到客户端,因为它们位于int枚举模式中。 最后,可以通过调用其toString方法将枚举转换为可打印的字符串。

除了纠正int枚举的缺陷之外,枚举类型还允许添加任意方法和属性并实现任意接口。 它们提供了所有Object方法的高质量实现(第3章),它们实现了Comparable(条目 14)和Serializable(第12章),并针对枚举类型的可任意改变性设计了序列化方式。

那么,为什么你要添加方法或属性到一个枚举类型? 对于初学者,可能想要将数据与其常量关联起来。 例如,我们的Apple和Orange类型可能会从返回水果颜色的方法或返回水果图像的方法中受益。 还可以使用任何看起来合适的方法来增强枚举类型。 枚举类型可以作为枚举常量的简单集合,并随着时间的推移而演变为全功能抽象。

对于丰富的枚举类型的一个很好的例子,考虑我们太阳系的八颗行星。 每个行星都有质量和半径,从这两个属性可以计算出它的表面重力。 从而在给定物体的质量下,计算出一个物体在行星表面上的重量。 下面是这个枚举类型。 每个枚举常量之后的括号中的数字是传递给其构造方法的参数。 在这种情况下,它们是地球的质量和半径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Enum type with data and behavior
public enum Planet {
MERCURY(3.302e+23, 2.439e6),
VENUS (4.869e+24, 6.052e6),
EARTH (5.975e+24, 6.378e6),
MARS (6.419e+23, 3.393e6),
JUPITER(1.899e+27, 7.149e7),
SATURN (5.685e+26, 6.027e7),
URANUS (8.683e+25, 2.556e7),
NEPTUNE(1.024e+26, 2.477e7);


private final double mass; // In kilograms
private final double radius; // In meters
private final double surfaceGravity; // In m / s^2
// Universal gravitational constant in m^3 / kg s^2
private static final double G = 6.67300E-11;


// Constructor
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
surfaceGravity = G * mass / (radius * radius);
}


public double mass() { return mass; }
public double radius() { return radius; }
public double surfaceGravity() { return surfaceGravity; }


public double surfaceWeight(double mass) {
return mass * surfaceGravity; // F = ma
}
}

编写一个丰富的枚举类型比如Planet很容易。 要将数据与枚举常量相关联,请声明实例属性并编写一个构造方法,构造方法带有数据并将数据保存在属性中。 枚举本质上是不变的,所以所有的属性都应该是final的(条目 17)。 属性可以是公开的,但最好将它们设置为私有并提供公共访问方法(条目16)。 在Planet的情况下,构造方法还计算和存储表面重力,但这只是一种优化。 每当重力被SurfaceWeight方法使用时,它可以从质量和半径重新计算出来,该方法返回它在由常数表示的行星上的重量。

虽然Planet枚举很简单,但它的功能非常强大。 这是一个简短的程序,它将一个物体在地球上的重量(任何单位),打印一个漂亮的表格,显示该物体在所有八个行星上的重量(以相同单位):

1
2
3
4
5
6
7
8
9
public class WeightTable {
public static void main(String[] args) {
double earthWeight = Double.parseDouble(args[0]);
double mass = earthWeight / Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values())
System.out.printf("Weight on %s is %f%n",
p, p.surfaceWeight(mass));
}
}

请注意,Planet和所有枚举一样,都有一个静态values方法,该方法以声明的顺序返回其值的数组。 另请注意,toString方法返回每个枚举值的声明名称,使printlnprintf可以轻松打印。 如果你对此字符串表示形式不满意,可以通过重写toString方法来更改它。 这是使用命令行参数185运行WeightTable程序(不重写toString)的结果:

1
2
3
4
5
6
7
8
Weight on MERCURY is 69.912739
Weight on VENUS is 167.434436
Weight on EARTH is 185.000000
Weight on MARS is 70.226739
Weight on JUPITER is 467.990696
Weight on SATURN is 197.120111
Weight on URANUS is 167.398264
Weight on NEPTUNE is 210.208751

直到2006年,在Java中加入枚举两年之后,冥王星不再是一颗行星。 这引发了一个问题:“当你从枚举类型中移除一个元素时会发生什么?”答案是,任何不引用移除元素的客户端程序都将继续正常工作。 所以,举例来说,我们的WeightTable程序只需要打印一行少一行的表格。 什么是客户端程序引用删除的元素(在这种情况下,Planet.Pluto)? 如果重新编译客户端程序,编译将会失败并在引用前一个星球的行处提供有用的错误消息; 如果无法重新编译客户端,它将在运行时从此行中引发有用的异常。 这是你所希望的最好的行为,远远好于你用int枚举模式得到的结果。

一些与枚举常量相关的行为只需要在定义枚举的类或包中使用。 这些行为最好以私有或包级私有方式实现。 然后每个常量携带一个隐藏的行为集合,允许包含枚举的类或包在与常量一起呈现时作出适当的反应。 与其他类一样,除非你有一个令人信服的理由将枚举方法暴露给它的客户端,否则将其声明为私有的,如果需要的话将其声明为包级私有(条目 15)。

如果一个枚举是广泛使用的,它应该是一个顶级类; 如果它的使用与特定的顶级类绑定,它应该是该顶级类的成员类(条目 24)。 例如,java.math.RoundingMode枚举表示小数部分的舍入模式。 BigDecimal类使用了这些舍入模式,但它们提供了一种有用的抽象,它并不与BigDecimal有根本的联系。 通过将RoundingMode设置为顶层枚举,类库设计人员鼓励任何需要舍入模式的程序员重用此枚举,从而提高跨API的一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Enum type that switches on its own value - questionable
public enum Operation {
PLUS, MINUS, TIMES, DIVIDE;

// Do the arithmetic operation represented by this constant
public double apply(double x, double y) {
switch(this) {
case PLUS: return x + y;
case MINUS: return x - y;
case TIMES: return x * y;
case DIVIDE: return x / y;
}
throw new AssertionError("Unknown op: " + this);
}
}

此代码有效,但不是很漂亮。 如果没有throw语句,就不能编译,因为该方法的结束在技术上是可达到的,尽管它永远不会被达到[JLS,14.21]。 更糟的是,代码很脆弱。 如果添加新的枚举常量,但忘记向switch语句添加相应的条件,枚举仍然会编译,但在尝试应用新操作时,它将在运行时失败。

幸运的是,有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的apply方法,并用常量特定的类主体中的每个常量的具体方法重写它。 这种方法被称为特定于常量(constant-specific)的方法实现:

1
2
3
4
5
6
7
8
9
// Enum type with constant-specific method implementations
public enum Operation {
PLUS {public double apply(double x, double y){return x + y;}},
MINUS {public double apply(double x, double y){return x - y;}},
TIMES {public double apply(double x, double y){return x * y;}},
DIVIDE{public double apply(double x, double y){return x / y;}};

public abstract double apply(double x, double y);
}

如果向第二个版本的操作添加新的常量,则不太可能会忘记提供apply方法,因为该方法紧跟在每个常量声明之后。 万一忘记了,编译器会提醒你,因为枚举类型中的抽象方法必须被所有常量中的具体方法重写。

特定于常量的方法实现可以与特定于常量的数据结合使用。 例如,以下是Operation的一个版本,它重写toString方法以返回通常与该操作关联的符号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Enum type with constant-specific class bodies and data
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};


private final String symbol;


Operation(String symbol) { this.symbol = symbol; }


@Override public String toString() { return symbol; }


public abstract double apply(double x, double y);
}

显示的toString实现可以很容易地打印算术表达式,正如这个小程序所展示的那样:

1
2
3
4
5
6
7
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n",
x, op, y, op.apply(x, y));
}

以2和4作为命令行参数运行此程序会生成以下输出:

1
2
3
4
2.000000 + 4.000000 = 6.000000
2.000000 - 4.000000 = -2.000000
2.000000 * 4.000000 = 8.000000
2.000000 / 4.000000 = 0.500000

枚举类型具有自动生成的valueOf(String)方法,该方法将常量名称转换为常量本身。 如果在枚举类型中重写toString方法,请考虑编写fromString方法将自定义字符串表示法转换回相应的枚举类型。 下面的代码(类型名称被适当地改变)将对任何枚举都有效,只要每个常量具有唯一的字符串表示形式:

1
2
3
4
5
6
7
8
9
// Implementing a fromString method on an enum type
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(
toMap(Object::toString, e -> e));

// Returns Operation for string, if any
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}

请注意,Operation枚举常量被放在stringToEnum的map中,它来自于创建枚举常量后运行的静态属性初始化。前面的代码在values()方法返回的数组上使用流(第7章);在Java 8之前,我们创建一个空的hashMap并遍历值数组,将字符串到枚举映射插入到map中,如果愿意,仍然可以这样做。但请注意,尝试让每个常量都将自己放入来自其构造方法的map中不起作用。这会导致编译错误,这是好事,因为如果它是合法的,它会在运行时导致NullPointerException。除了编译时常量属性(条目 34)之外,枚举构造方法不允许访问枚举的静态属性。此限制是必需的,因为静态属性在枚举构造方法运行时尚未初始化。这种限制的一个特例是枚举常量不能从构造方法中相互访问。

另请注意,fromString方法返回一个Optional<String>。 这允许该方法指示传入的字符串不代表有效的操作,并且强制客户端面对这种可能性(条目 55)。

特定于常量的方法实现的一个缺点是它们使得难以在枚举常量之间共享代码。 例如,考虑一个代表工资包中的工作天数的枚举。 该枚举有一个方法,根据工人的基本工资(每小时)和当天工作的分钟数计算当天工人的工资。 在五个工作日内,任何超过正常工作时间的工作都会产生加班费; 在两个周末的日子里,所有工作都会产生加班费。 使用switch语句,通过将多个case标签应用于两个代码片段中的每一个,可以轻松完成此计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Enum that switches on its value to share code - questionable
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
SATURDAY, SUNDAY;

private static final int MINS_PER_SHIFT = 8 * 60;

int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;

int overtimePay;
switch(this) {
case SATURDAY: case SUNDAY: // Weekend
overtimePay = basePay / 2;
break;
default: // Weekday
overtimePay = minutesWorked <= MINS_PER_SHIFT ?
0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}

return basePay + overtimePay;
}
}

这段代码无可否认是简洁的,但从维护的角度来看是危险的。 假设你给枚举添加了一个元素,可能是一个特殊的值来表示一个假期,但忘记在switch语句中添加一个相应的case条件。 该程序仍然会编译,但付费方法会默默地为工作日支付相同数量的休假日,与普通工作日相同。

要使用特定于常量的方法实现安全地执行工资计算,必须为每个常量重复加班工资计算,或将计算移至两个辅助方法,一个用于工作日,另一个用于周末,并调用适当的辅助方法来自每个常量。 这两种方法都会产生相当数量的样板代码,大大降低了可读性并增加了出错机会。

通过使用执行加班计算的具体方法替换PayrollDay上的抽象overtimePay方法,可以减少样板。 那么只有周末的日子必须重写该方法。 但是,这与switch语句具有相同的缺点:如果在不重写overtimePay方法的情况下添加另一天,则会默默继承周日计算方式。

你真正想要的是每次添加枚举常量时被迫选择加班费策略。 幸运的是,有一个很好的方法来实现这一点。 这个想法是将加班费计算移入私有嵌套枚举中,并将此策略枚举的实例传递给PayrollDay枚举的构造方法。 然后,PayrollDay枚举将加班工资计算委托给策略枚举,从而无需在PayrollDay中实现switch语句或特定于常量的方法实现。 虽然这种模式不如switch语句简洁,但它更安全,更灵活:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// The strategy enum pattern
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);


private final PayType payType;


PayrollDay(PayType payType) { this.payType = payType; }
PayrollDay() { this(PayType.WEEKDAY); } // Default


int pay(int minutesWorked, int payRate) {
return payType.pay(minutesWorked, payRate);
}


// The strategy enum type
private enum PayType {
WEEKDAY {
int overtimePay(int minsWorked, int payRate) {
return minsWorked <= MINS_PER_SHIFT ? 0 :
(minsWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
int overtimePay(int minsWorked, int payRate) {
return minsWorked * payRate / 2;
}
};


abstract int overtimePay(int mins, int payRate);
private static final int MINS_PER_SHIFT = 8 * 60;


int pay(int minsWorked, int payRate) {
int basePay = minsWorked * payRate;
return basePay + overtimePay(minsWorked, payRate);
}
}
}

如果对枚举的switch语句不是实现常量特定行为的好选择,那么它们有什么好处呢?枚举类型的switch有利于用常量特定的行为增加枚举类型。例如,假设Operation枚举不在你的控制之下,你希望它有一个实例方法来返回每个相反的操作。你可以用以下静态方法模拟效果:

1
2
3
4
5
6
7
8
9
10
11
12
// Switch on an enum to simulate a missing method
public static Operation inverse(Operation op) {
switch(op) {
case PLUS: return Operation.MINUS;
case MINUS: return Operation.PLUS;
case TIMES: return Operation.DIVIDE;
case DIVIDE: return Operation.TIMES;


default: throw new AssertionError("Unknown op: " + op);
}
}

如果某个方法不属于枚举类型,则还应该在你控制的枚举类型上使用此技术。 该方法可能需要用于某些用途,但通常不足以用于列入枚举类型。

一般而言,枚举通常在性能上与int常数相当。 枚举的一个小小的性能缺点是加载和初始化枚举类型存在空间和时间成本,但在实践中不太可能引人注意。

那么你应该什么时候使用枚举呢? 任何时候使用枚举都需要一组常量,这些常量的成员在编译时已知。 当然,这包括“天然枚举类型”,如行星,星期几和棋子。 但是它也包含了其它你已经知道编译时所有可能值的集合,例如菜单上的选项,操作代码和命令行标志。** 一个枚举类型中的常量集不需要一直保持不变**。 枚举功能是专门设计用于允许二进制兼容的枚举类型的演变。

总之,枚举类型优于int常量的优点是令人信服的。 枚举更具可读性,更安全,更强大。 许多枚举不需要显式构造方法或成员,但其他人则可以通过将数据与每个常量关联并提供行为受此数据影响的方法而受益。 使用单一方法关联多个行为可以减少枚举。 在这种相对罕见的情况下,更喜欢使用常量特定的方法来枚举自己的值。 如果一些(但不是全部)枚举常量共享共同行为,请考虑策略枚举模式。

35. 使用实例属性替代序数

许多枚举通常与单个int值关联。所有枚举都有一个ordinal方法,它返回每个枚举常量类型的数值位置。你可能想从序数中派生一个关联的int值:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Abuse of ordinal to derive an associated value - DON'T DO THIS

public enum Ensemble {

SOLO, DUET, TRIO, QUARTET, QUINTET,

SEXTET, SEPTET, OCTET, NONET, DECTET;



public int numberOfMusicians() { return ordinal() + 1; }

}

虽然这个枚举能正常工作,但对于维护来说则是一场噩梦。如果常量被重新排序,numberOfMusicians方法将会中断。 如果你想添加一个与你已经使用的int值相关的第二个枚举常量,则没有那么好运了。 例如,为双四重奏(double quartet)添加一个常量可能会很好,它就像八重奏一样,由8位演奏家组成,但是没有办法做到这一点。

此外,如果没有给所有这些int值添加常量,也不能为某个int值添加一个常量。例如,假设你想要添加一个常量,表示一个由12位演奏家组成的三重四重奏(triple quartet)。对于由11个演奏家组成的合奏曲,并没有标准的术语,因此你不得不为未使用的int值(11)添加一个虚拟常量(dummy constant)。最多看起来就是有些不好看。如果许多int值是未使用的,则是不切实际的。

幸运的是,这些问题有一个简单的解决方案。 永远不要从枚举的序号中得出与它相关的值; 请将其保存在实例属性中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum Ensemble {

SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),

SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),

NONET(9), DECTET(10), TRIPLE_QUARTET(12);



private final int numberOfMusicians;

Ensemble(int size) { this.numberOfMusicians = size; }

public int numberOfMusicians() { return numberOfMusicians; }

}

枚举规范对此ordinal方法说道:“大多数程序员对这种方法没有用处。 它被设计用于基于枚举的通用数据结构,如EnumSetEnumMap。“除非你在编写这样数据结构的代码,否则最好避免使用ordinal方法。

36. 使用EnumSet替代位属性

如果枚举类型的元素主要用于集合中,一般来说使用int枚举模式(条目 34),下面将2的不同倍数赋值给每个常量:

1
2
3
4
5
6
7
8
9
10
// Bit field enumeration constants - OBSOLETE!
public class Text {
public static final int STYLE_BOLD = 1 << 0; // 1
public static final int STYLE_ITALIC = 1 << 1; // 2
public static final int STYLE_UNDERLINE = 1 << 2; // 4
public static final int STYLE_STRIKETHROUGH = 1 << 3; // 8

// Parameter is bitwise OR of zero or more STYLE_ constants
public void applyStyles(int styles) { ... }
}

这种表示方式允许你使用按位或(or)运算将几个常量合并到一个称为位属性(bit field)的集合中:

1
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);

位属性表示还允许你使用按位算术有效地执行集合运算,如并集和交集。 但是位属性具有int枚举常量等的所有缺点。 当打印为数字时,解释位属性比简单的int枚举常量更难理解。 没有简单的方法遍历所有由位属性表示的元素。 最后,必须预测在编写API时需要的最大位数,并相应地为位属性(通常为int或long)选择一种类型。 一旦你选择了一个类型,你就不能超过它的宽度(32或64位)而不改变API。

一些程序员使用枚举优于int常量,当他们需要传递常量集合时仍然使用位属性。 没有理由这样做,因为存在更好的选择。 java.util包提供了EnumSet类来有效地表示从单个枚举类型中提取的值集合。 这个类实现了Set接口,提供了所有其他Set实现的丰富性,类型安全性和互操作性。 但是在内部,每个EnumSet都表示为一个位矢量(bit vector)。 如果底层的枚举类型有64个或更少的元素,并且大多数情况下,整个EnumSet用单个long表示,所以它的性能与位属性的性能相当。 批量操作(如removeAll和retainAll)是使用按位算术实现的,就像你为位属性手动操作一样。 但是完全避免了手动位混乱的丑陋和错误倾向:EnumSet为你做了很大的努力。

下面是前一个使用枚举和枚举集合替代位属性的示例。 它更短,更清晰,更安全:

1
2
3
4
5
6
7
// EnumSet - a modern replacement for bit fields
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }

// Any Set could be passed in, but EnumSet is clearly best
public void applyStyles(Set<Style> styles) { ... }
}

这里是将EnumSet实例传递给applyStyles方法的客户端代码。 EnumSet类提供了一组丰富的静态工厂,可以轻松创建集合,其中一个代码如下所示:

1
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));

请注意,applyStyles方法采用Set<Style>而不是EnumSet<Style>参数。 尽管所有客户端都可能会将EnumSet传递给该方法,但接受接口类型而不是实现类型通常是很好的做法(条目 64)。 这允许一个不寻常的客户端通过其他Set实现的可能性。

总之,仅仅因为枚举类型将被用于集合中,所以没有理由用位属性来表示它EnumSet类将位属性的简洁性和性能与条目 34中所述的枚举类型的所有优点相结合。EnumSet的一个真正缺点是,它不像Java 9那样创建一个不可变的EnumSet,但是在即将发布的版本中可能会得到补救。 同时,你可以用Collections.unmodifiableSet封装一个EnumSet,但是简洁性和性能会受到影响。

37. 使用EnumMap替代序数索引

有时可能会看到使用ordinal方法(条目 35)来索引到数组或列表的代码。 例如,考虑一下这个简单的类来代表一种植物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Plant {
enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
final String name;
final LifeCycle lifeCycle;

Plant(String name, LifeCycle lifeCycle) {
[this.name](http://this.name) = name;
this.lifeCycle = lifeCycle;
}

@Override public String toString() {
return name;
}
}

现在假设你有一组植物代表一个花园,想要列出这些由生命周期组织的植物(一年生,多年生,或双年生)。为此,需要构建三个集合,每个生命周期作为一个,并遍历整个花园,将每个植物放置在适当的集合中。一些程序员可以通过将这些集合放入一个由生命周期序数索引的数组中来实现这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Using ordinal() to index into an array - DON'T DO THIS!

Set<Plant>[] plantsByLifeCycle =

(Set<Plant>[]) new Set[Plant.LifeCycle.values().length];

for (int i = 0; i < plantsByLifeCycle.length; i++)

plantsByLifeCycle[i] = new HashSet<>();

for (Plant p : garden)

plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);

// Print the results

for (int i = 0; i < plantsByLifeCycle.length; i++) {

System.out.printf("%s: %s%n",

Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);

}

这种方法是有效的,但充满了问题。 因为数组不兼容泛型(条目 28),程序需要一个未经检查的转换,并且不会干净地编译。 由于该数组不知道索引代表什么,因此必须手动标记索引输出。 但是这种技术最严重的问题是,当你访问一个由枚举序数索引的数组时,你有责任使用正确的int值; int不提供枚举的类型安全性。 如果你使用了错误的值,程序会默默地做错误的事情,如果你幸运的话,抛出一个ArrayIndexOutOfBoundsException异常。

有一个更好的方法来达到同样的效果。 该数组有效地用作从枚举到值的映射,因此不妨使用Map。 更具体地说,有一个非常快速的Map实现,设计用于枚举键,称为java.util.EnumMap。 下面是当程序重写为使用EnumMap时的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Using an EnumMap to associate data with an enum

Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =

new EnumMap<>(Plant.LifeCycle.class);

for (Plant.LifeCycle lc : Plant.LifeCycle.values())

plantsByLifeCycle.put(lc, new HashSet<>());

for (Plant p : garden)

plantsByLifeCycle.get(p.lifeCycle).add(p);

System.out.println(plantsByLifeCycle);

这段程序更简短,更清晰,更安全,运行速度与原始版本相当。 没有不安全的转换; 无需手动标记输出,因为map键是知道如何将自己转换为可打印字符串的枚举; 并且不可能在计算数组索引时出错。 EnumMap与序数索引数组的速度相当,其原因是EnumMap内部使用了这样一个数组,但它对程序员的隐藏了这个实现细节,将Map的丰富性和类型安全性与数组的速度相结合。 请注意,EnumMap构造方法接受键类型的Class对象:这是一个有限定的类型令牌(bounded type token),它提供运行时的泛型类型信息(条目 33)。

通过使用stream(条目 45)来管理Map,可以进一步缩短以前的程序。 以下是最简单的基于stream的代码,它们在很大程度上重复了前面示例的行为:

1
2
3
4
5
// Naive stream-based approach - unlikely to produce an EnumMap!

System.out.println(Arrays.stream(garden)

.collect(groupingBy(p -> p.lifeCycle)));

这个代码的问题在于它选择了自己的Map实现,实际上它不是EnumMap,所以它不会与显式EnumMap的版本的空间和时间性能相匹配。 为了解决这个问题,使用Collectors.groupingBy的三个参数形式的方法,它允许调用者使用mapFactory参数指定map的实现:

1
2
3
4
5
6
7
// Using a stream and an EnumMap to associate data with an enum

System.out.println(Arrays.stream(garden)

.collect(groupingBy(p -> p.lifeCycle,

() -> new EnumMap<>(LifeCycle.class), toSet())));

这样的优化在像这样的示例程序中是不值得的,但是在大量使用Map的程序中可能是至关重要的。

基于stream版本的行为与EmumMap版本的行为略有不同。 EnumMap版本总是为每个工厂生命周期生成一个嵌套map类,而如果花园包含一个或多个具有该生命周期的植物时,则基于流的版本才会生成嵌套map类。 因此,例如,如果花园包含一年生和多年生植物但没有两年生的植物,plantByLifeCycle的大小在EnumMap版本中为三个,在两个基于流的版本中为两个。

你可能会看到数组索引(两次)的数组,用序数来表示从两个枚举值的映射。例如,这个程序使用这样一个数组来映射两个阶段到一个阶段转换(phase transition)(液体到固体表示凝固,液体到气体表示沸腾等等):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Using ordinal() to index array of arrays - DON'T DO THIS!

public enum Phase {

SOLID, LIQUID, GAS;

public enum Transition {

MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;

// Rows indexed by from-ordinal, cols by to-ordinal

private static final Transition[][] TRANSITIONS = {

{ null, MELT, SUBLIME },

{ FREEZE, null, BOIL },

{ DEPOSIT, CONDENSE, null }

};

// Returns the phase transition from one phase to another

public static Transition from(Phase from, Phase to) {

return TRANSITIONS[from.ordinal()][to.ordinal()];

}

}

}

这段程序可以运行,甚至可能显得优雅,但外观可能是骗人的。 就像前面显示的简单的花园示例一样,编译器无法知道序数和数组索引之间的关系。 如果在转换表中出错或者在修改PhasePhase.Transition枚举类型时忘记更新它,则程序在运行时将失败。 失败可能是ArrayIndexOutOfBoundsExceptionNullPointerException或(更糟糕的)沉默无提示的错误行为。 即使非空条目的数量较小,表格的大小也是phase的个数的平方。

同样,可以用EnumMap做得更好。 因为每个阶段转换都由一对阶段枚举来索引,所以最好将关系表示为从一个枚举(from 阶段)到第二个枚举(to阶段)到结果(阶段转换)的map。 与阶段转换相关的两个阶段最好通过将它们与阶段转换枚举相关联来捕获,然后可以用它来初始化嵌套的EnumMap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Using a nested EnumMap to associate data with enum pairs

public enum Phase {

SOLID, LIQUID, GAS;

public enum Transition {

MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),

BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),

SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);

private final Phase from;

private final Phase to;

Transition(Phase from, Phase to) {

this.from = from;

[this.to](http://this.to) = to;

}

// Initialize the phase transition map

private static final Map<Phase, Map<Phase, Transition>>

m = Stream.of(values()).collect(groupingBy(t -> t.from,

() -> new EnumMap<>(Phase.class),

toMap(t -> [t.to](http://t.to), t -> t,

(x, y) -> y, () -> new EnumMap<>(Phase.class))));

public static Transition from(Phase from, Phase to) {

return m.get(from).get(to);

}

}

}

初始化阶段转换的map的代码有点复杂。map的类型是Map<Phase, Map<Phase, Transition>>,意思是“从(源)阶段映射到从(目标)阶段到阶段转换映射。”这个map的map使用两个收集器的级联序列进行初始化。 第一个收集器按源阶段对转换进行分组,第二个收集器使用从目标阶段到转换的映射创建一个EnumMap。 第二个收集器((x, y) -> y))中的合并方法未使用;仅仅因为我们需要指定一个map工厂才能获得一个EnumMap,并且Collectors提供伸缩式工厂,这是必需的。 本书的前一版使用显式迭代来初始化阶段转换map。 代码更详细,但可以更容易理解。

现在假设想为系统添加一个新阶段:等离子体或电离气体。 这个阶段只有两个转变:电离,将气体转化为等离子体; 和去离子,将等离子体转化为气体。 要更新基于数组的程序,必须将一个新的常量添加到Phase,将两个两次添加到Phase.Transition,并用新的十六个元素版本替换原始的九元素阵列数组。 如果向数组中添加太多或太少的元素或者将元素乱序放置,那么如果运气不佳:程序将会编译,但在运行时会失败。 要更新基于EnumMap的版本,只需将PLASMA添加到阶段列表中,并将IONIZE(GAS, PLASMA)DEIONIZE(PLASMA, GAS)添加到阶段转换列表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Adding a new phase using the nested EnumMap implementation

public enum Phase {

SOLID, LIQUID, GAS, PLASMA;

public enum Transition {

MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),

BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),

SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),

IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS);

... // Remainder unchanged

}

}

该程序会处理所有其他事情,并且几乎不会出现错误。 在内部,map的map是通过数组的数组实现的,因此在空间或时间上花费很少,以增加清晰度,安全性和易于维护。

为了简便起见,上面的示例使用null来表示状态更改的缺失(其从目标到源都是相同的)。这不是很好的实践,很可能在运行时导致NullPointerException。为这个问题设计一个干净、优雅的解决方案是非常棘手的,而且结果程序足够长,以至于它们会偏离这个条目的主要内容。

总之,使用序数来索引数组很不合适:改用EnumMap。 如果你所代表的关系是多维的,请使用EnumMap <...,EnumMap <... >>。 应用程序员应该很少使用Enum.ordinal(条目 35),如果使用了,也是一般原则的特例。


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!