2024年六月月 发布的文章

Go与神经网络:线性回归

本文永久链接 – https://tonybai.com/2024/06/10/go-and-nn-part2-linear-regression

离发表上一篇与机器学习相关的文章《Go与神经网络:张量运算》已经过去整整一年了,AI领域,特别是大模型领域的热度不仅未有减弱,反而愈演愈烈。整个行业变得更卷,竞争更加激烈,大模型你方唱罢我登场,层出不穷,各自能力也都在不断提升,并在自然语言处理、问答、生成等方面展现出强大的能力。同时基于RAG(Retrieval-Augmented Generation)等技术,大模型还可以实时检索相关知识并融合到生成结果中,进一步提升了大模型在专业领域的应用价值。

很多人说用好大模型不必非要了解大模型的底层原理,也许这句话是对的。但对于后端程序员的我来说,对底层原理的不理解,始终让我有一种“不安全感”。我认为即使大模型的使用变得日益简单和广泛,但如果我们无法深入理解其工作机制,恐怕还是难以充分发挥它们的潜力,甚至无法准确评估它们的局限性和风险。

但对大模型原理的学习是一个循序渐进的学习过程,我们不能一蹴而就地达到对大模型原理的深入理解。我决定从最基础的机器学习入手,从传统机器学习解决问题的一般步骤开始,以线性回归这个传统传统机器学习的”Hello, World”示例为切入点,逐步探讨机器学习的基本概念和实现流程,这也是本篇文章的初衷与主要内容。

1. 机器学习的那些事儿

1.1 人工智能的诞生

相对于机器学习(Machine Learning,ML),普通大众更熟悉“人工智能(Artificial Intelligence,AI)”这个字眼儿。

就像一千个人眼中有一千个哈姆雷特,每个人对人工智能的理解都不尽相同:有些人将其看成一个学术领域,有些人视之为人类文明下一个要实现的目标,懵懂无知的少年会将其想象为那种高大威猛的机器人(其实是隶属于具身智能,人工智能和机器人学的一个跨学科分支),而还有些人认为它是空中楼阁,永远无法实现。

作为程序员,我们更聚焦工程领域。而工程领域主要是消化学术领域的研究,将其实现落地并应用于人类生活的方方面面。那么学术领域是如何定义人工智能的呢?人工智能专家Stuart RussellPeter Norvig在他们的联合著作《人工智能:现代方法(第4版)》中将人工智能定义为对从环境中接收感知并执行动作的智能体(Agent)的研究,并强调每个这样的智能体都要实现一个将感知序列映射为动作的函数。

对人工智能的定义虽然内容不长,但这里面却蕴含着计算机科学家对人工智能几十年的探索历程与尝试。

人工智能的概念始于20世纪50年代。1950年,阿兰·图灵(Alan Turing)发表了《计算机器与智能》一文,提出了著名的图灵测试。但人工智能一词的正式提出还要等到6年后的1956年达特茅斯会议。对于人工智能领域而言,这是堪比理论物理学界1927年比利时第五届索尔维会议(如下图)的一次会议。约翰·麦卡锡(John McCarthy)、马文·闵斯基(Marvin Minsky,人工智能与认知学专家)、克劳德·香农(Claude Shannon,信息论的创始人)、艾伦·纽厄尔(Allen Newell,计算机科学家)、赫伯特·西蒙(Herbert Simon,诺贝尔经济学奖得主)等科学家正聚在一起,讨论着一个完全不食人间烟火的主题:用机器来模仿人类学习以及其他方面的智能。会议足足开了两个月的时间,虽然大家没有达成普遍的共识,但是却为会议讨论的内容起了一个名字:人工智能。因此,1956年也就成为了人工智能元年。


1927年比利时第五届索尔维会议合影

1.2 符号主义:早期的人工智能实现路径

在人工智能的早期探索阶段,研究主要集中于符号主义(Symbolism)和逻辑推理。这种方法使用符号来表示知识和问题,并通过逻辑推理来解决问题。这种方法依赖于明确的规则和符号系统来进行推理和决策。

逻辑推理是符号主义的核心,它使用逻辑规则来进行推理和决策。逻辑推理包括演绎推理、归纳推理和溯因推理:

  • 演绎推理:从一般规则推导出特定结论(例如,从“所有人都会死”推导出“苏格拉底会死”)。
  • 归纳推理:从特定实例推导出一般规则(例如,从“苏格拉底会死”推导出“所有人都会死”)。
  • 溯因推理:从结果推断出可能的原因(例如,从“苏格拉底死了”推断出“他是人”)。

LISP语言在符号主义和逻辑推理盛行的阶段发挥了重要作用,其强大的符号处理能力、递归和动态特性、交互式开发环境以及宏和元编程功能使其成为AI研究和开发的主要工具。其他语言如Prolog和Scheme也在特定领域中提供了重要支持。

虽然在机器学习大行其道的今天,符号主义系统不受待见了,但不可否认符号主义系统也有很多优点:

  • 可解释性:符号主义系统的推理过程透明、易于理解。
  • 明确规则:使用明确的规则和逻辑,使得系统行为可预测。
  • 可靠性:在明确定义的领域内,符号主义系统可以提供可靠的推理结果。

不过,它的缺点也很明显:

  • 局限性: 依赖于预定义的规则和知识库,难以处理复杂和动态的环境。
  • 知识获取: 知识工程需要大量人工干预,获取和维护知识库成本高。

正是由于这些这些不足,以及当时计算能力和数据存储的限制,AI研究在1970年代遇到了障碍,进入了第一次“AI寒冬”。许多早期的承诺未能实现,资金和兴趣减少。

1980年代,符号主义和逻辑推理迎来了第二次巅峰:知识工程和专家系统。专家系统是计算机程序,旨在模仿人类专家的决策能力。它们在特定领域内使用显式编码的知识库和推理引擎来解决复杂问题或提供建议。第一个成功的商用专家系统R1在数字设备公司(Digital Equipment Corporation,DEC)投入使用(McDermott, 1982),该程序帮助公司配置新计算机系统的订单。截至1986年,它每年为公司节省约4000万美元。到1988年,DEC的人工智能小组已经部署了40个专家系统,而且还有更多的专家系统在开发中。但事实证明,为复杂领域构建和维护专家系统是困难的,一部分原因是系统使用的推理方法在面临不确定性时会崩溃,另一部分原因是系统无法从经验中学习。专家系统的局限性和开发维护成本直接也导致了第二次“AI寒冬”的到来。

在两次AI的兴起和“寒冬”中,先行研究者们开发了许多基础性的算法和系统。这些早期研究不仅解决了特定问题,还为AI的理论和实践发展奠定了重要的基础。随着计算能力和数据资源的增加,AI研究逐渐从符号主义转向数据驱动的方法,但这些早期成果仍然具有重要的历史和学术意义。

1.3 数据驱动与机器学习

数据驱动方法依赖于大量数据,通过从数据中学习模式和关系来进行预测和决策。这种方法不依赖于明确的布尔逻辑和规则,而是通过统计和算法来从数据中提取知识,即基于机器学习而不是手工编码。

1990年代至2000年代,随着计算能力的提升和数据量的增加,机器学习(ML)逐渐成为AI的主要方法。基于统计学和概率论的算法(如支持向量机和决策树)获得了成功。大数据的可用性和向机器学习的转变帮助人工智能恢复了商业吸引力。大数据是2011年IBM的Watson系统在《危险边缘》(Jeopardy!)问答游戏中战胜人类冠军的关键因素,这一事件深深影响了公众对人工智能的看法。

《人工智能:现代方法(第4版)》是这样定义机器学习的:如果一个智能体通过对世界进行观测来提高它的性能,我们称其为智能体学习(learning)。学习可以是简单的,例如记录一个购物清单,也可以是复杂的,例如爱因斯坦推断关于宇宙的新理论。当智能体是一台计算机时,我们称之为机器学习(machine learning):一台计算机观测到一些数据,基于这些数据构建一个模型(model),并将这个模型作为关于世界的一个假设(hypothesis)以及用于求解问题的软件的一部分。不通过合适的方式编程来解决,而是希望一台机器自主进行学习并解决问题,其原因主要有两个:

  • 程序的设计者无法预见未来所有可能发生的情形。比如一个被设计用来导航迷宫的机器人无法掌握每一个它可能遇到的新迷宫的布局。
  • 有时候设计者并不知道如何设计一个程序来求解目标问题。比如识别人脸。

可以说,机器学习是另外一种实现人工智能的路径(前一种是符号主义和逻辑推理),它是一类强大的可以从经验中学习的技术。通常采用观测数据或与环境交互的形式,机器学习算法会积累更多的经验,其性能也会逐步提高。

机器学习的兴起同样离不开早期研究者的成果:

  • 感知机(1957): Frank Rosenblatt 设计的感知机是第一个用于分类任务的人工神经网络模型,能够学习二分类任务。感知机也被视为一种最简单形式的前馈式人工神经网络
  • K均值聚类(1967): James MacQueen 提出的K均值聚类算法,是最早的聚类分析方法之一。
  • 决策树(ID3, 1986): Ross Quinlan 提出的 ID3 算法,是决策树学习的基础。
  • 支持向量机(1992): Vladimir Vapnik 和 Alexey Chervonenkis 提出了支持向量机,为高维数据分类问题提供了强有力的解决方案。
  • 多层感知器(1986): 由 Geoffrey Hinton 等人推广的反向传播算法(Backpropagation),使得训练多层神经网络成为可能。
  • 梯度提升树(2000): Jerome Friedman 提出的梯度提升树(Gradient Boosting Machines),在分类和回归任务中表现出色。
  • 随机森林(2001): Leo Breiman 提出的随机森林算法,通过集成多个决策树提高了模型的准确性和鲁棒性。

进入2010年后,在大规模数据集以及GPU硬件加速的赋能下,深度神经网络逐渐成为主流且表现卓越的机器学习方案,深度学习走向前台:

  • AlexNet(2012): Alex Krizhevsky 等人在 ImageNet 大赛上使用卷积神经网络(CNN)赢得了第一名,推动了深度学习在计算机视觉中的应用。
  • 生成对抗网络(GAN, 2014): Ian Goodfellow 等人提出的生成对抗网络,开启了生成模型的新方向。
  • BERT(2018): Google 提出的双向编码器表示(BERT)模型,在自然语言处理任务中取得了突破性进展。
  • Transformers(2022):Transformers模型及其变种在自然语言处理、图像处理等多个领域取得了显著进展,典型代表是ChatGPT的推出。

1.4 人工智能关系图

基于上面的说明,我们下面用一张图说明一下人工智能、机器学习、神经网络以及深度学习的关系:

而神经网络是支撑机器学习的重要技术,是深度学习的核心技术。关于神经网络,我们会在后面的系列文章中重点说明。

网络上也有一个图,可以更详细地展示各个范围内的具体技术,大家也可以参考一下:

1.5 机器学习的本质

机器学习就是从数据中发现规律,发现的这个规律就是“模型”, 更具体来说就是一个或一组复合在一起的函数。而发现规律的这个过程就叫“学习”或叫“训练”。这个过程与人类学习的有些相似:

人类和机器都需要输入信息来开始学习。人类通过感官感知信息,机器通过传感器或数据集获取数据。人类通过理解和记忆进行学习,机器通过训练数据调整模型参数进行学习。人类在大脑中存储知识和经验,机器在模型参数和结构中存储学到的模式和规则。人类根据实践中的反馈调整和改进知识,机器根据评估和实际应用中的反馈调整模型参数和结构。两者尽管实现手段不同,但核心思想都是从输入数据中学习知识和模式,通过反馈进行调整和改进,并不断适应新的环境和问题。

上图中使用神经网络的形式呈现了学习/训练后的模型,其实在一些传统机器学习的简单场景下,训练后的模型可能就是一个简单的一元线性函数,比如:f(x) = wx + h。

训练后的模型便可以应用于真实环境中的数据,进行推理和预测(serve/predict)。比如说,一个经过大量真实病历数据训练后得到医疗诊断模型,就可以用来预测和诊断新的病患情况了。

到这里,你可能依然对机器学习一知半解。别着急,之前我也是这样,就想亲手训练一个模型来直观体会一下什么是机器学习。接下来,我们就来训练一个Hello,World级别的模型,不过在真正动手之前,我们还是要先来了解一下机器学习中的术语(“黑话”)与训练的一般步骤。

2. 机器学习的术语与一般步骤

机器学习本身就有不低的门槛,因此我们将由浅入深的来学习机器学习的术语,并简要说明一下机器学习项目的一般步骤。

2.1 特征、标签与模型

在下图中,我们以一个简单的多元线性回归模型(即一个多元一次函数)来说明一下一些机器学习中常见的术语:

我们先介绍与数据有关的几个重要术语,其他在后面说明机器学习的一般步骤时,结合具体的场景再行讲解。机器学习离不开数据,如上图中左上角的表格就是“喂给”机器学习训练的训练数据集(training dataset )

上图中的数据集是一个常见的房价相关数据,该数据集有三条数据,它们组成了该数据集的数据样本。表中每条数据有三个影响房价的“因子”:居住面积、离市中心距离和建成时间(也就是房龄),这些因子共同决定了房子的价格。在机器学习中,我们称这些“因子”为特征(feature)。而房价则被称为标签(label)。从数据来看,这三个特征表现出明显的与房价(y)的相关性,如下图:

机器学习的目的就是找到通过特征预测标签的函数(即模型),然后将得到的函数应用于生产中进行标签预测。特征是机器学习模型的输入,标签是机器学习模型的输出。无论是在训练阶段,还是在预测阶段。特征的个数称为特征的维度,维度越高,数据集越复杂。

了解完特征、标签和模型后,我们来看看机器学习项目的一般步骤,更具体来说就是机器学习训练的步骤,一旦训练ok,得到模型,模型应用就比较简单了。

2.2 机器学习训练的一般步骤

上图展示了机器学习训练的一般步骤,我们逐个说明一下。

2.2.1 数据收集与预处理

就像人类要从各种资料(书籍、媒体等)中学习一样,机器也要从数据中学习。没有数据,机器学习就无从谈起。数据也是通过机器学习解决生活中实际问题的前提。

数据收集渠道有多种,有爬取互联网的数据,有开源数据集(Image Net、Kaggle、Google Public Data Explorer),有购买的,还有客户积攒的海量历史数据等。这些数据拿到手后,还不能直接喂给模型进行训练,因为业界有句名言“输入的是垃圾,输出的也是垃圾”(Garbage in, garbage out),我们需要对数据进行分析和预处理,了解数据内在关系并使其满足机器学习训练的规格和质量要求,最后还需要做特征的提取,即使用数据的领域知识来创建/识别出那些使机器学习算法起作用的特征的过程。

数据的预处理是十分重要的工作,预处理的好坏直接决定了训练出来的机器学习模型的有效性。在《零基础学习机器学习》一书中提到了数据预处理工作包含的几项内容:

  • 可视化:用Excel表和各种数据分析工具(如Matplotlib等)从各种角度(如列表、直方图、散点图等)探索一下数据。对数据有了基本的了解后,才方便进一步分析判断,即为后续的模型选择奠定基础。
  • 数据向量化:把原始数据格式化,使其变得机器可以读取。例如,将原始图片转换为机器可以读取的数字矩阵,将文字转换为one-hot编码,将文本类别(如男、女)转换成0、1这样的数值。
  • 处理坏数据和空数据:一条数据可不是全部都能用,要利用数据处理工具来把“捣乱”的“坏数据”(冗余数据、离群数据、错误数据)处理掉,把缺失值补充上。
  • 特征缩放:可以显著提升模型的性能和训练效率。许多机器学习算法,例如梯度下降法,依赖于特征之间的距离计算。如果特征的尺度差异很大,会导致算法在不同特征方向上以不同的速度进行更新,从而降低收敛速度。特征缩放可以将所有特征缩放到相同的尺度,使算法能够更快地收敛到最优解。特征尺度差异过大可能导致数值计算不稳定,例如出现梯度爆炸或梯度消失现象,影响模型训练效果。特征缩放还可以使模型的权重更加可解释。当特征尺度差异很大时,模型的权重可能无法反映特征的实际重要性。特征缩放可以使权重更加反映特征的真实贡献。

特征缩放适用于大多数机器学习算法,包括线性回归、逻辑回归、支持向量机、神经网络等。常见的特征缩放方法包括如下几种:

  • 标准化 (Standardization):对数据特征分布的转换,目标是使其符合正态分布(均值为0,标准差为1)。在实践中,会去除特征的均值来转换数据, 使其居中,然后除以特征的标准差来对其进行缩放。
  • 归一化/规范化 (Normalization):将特征数据缩放到特定范围,通常是0到1之间。归一化不会改变数据的分布形态。

数据预处理还包括特征工程和特征提取,即确定数据中究竟哪个特征对问题的解决会起到关键作用,并提取出来作为后续训练和预测的输入特征。许多现代机器学习算法,如深度学习模型,可以从原始数据中学习复杂的表示形式,而不需要明确的特征工程,但是特征工程仍然在机器学习工作流程中扮演着重要角色,尤其是在领域知识、可解释性和数据质量方面起到重要作用。不过特征提取是一个细分领域,内容很多(对之我也不甚了解),这里就不展开说了。

2.2.2 选择机器学习模型

AI科学家期望能有一个通用的机器学习模型可以学习一切类型的数据,并处理所有领域的任务,这样世界将变得简单了。但就目前AI发展的水平来看,还没有一个通用的机器学习模型可以适合于所有类型的数据和任务,即便是当今大热的预训练的大语言模型也可能不胜任某一领域的工作。在前期的传统机器学习阶段,不同的数据和问题需要采用不同的机器学习方法和模型。

影响机器学习模型选择的一些关键的因素包括:

  • 数据类型和特征:比如图像数据和文本数据一般需要不同的模型。数据的维度、稀疏程度等也会影响选择的模型。
  • 任务类型:分类、回归、聚类等任务适合不同的模型。有监督学习和无监督学习也需要不同的方法。
  • 数据规模:对于大规模数据,可扩展性强的模型如深度学习效果更好。小样本数据可能更适合传统的机器学习算法。
  • 领域知识:某些领域问题需要结合专业领域知识,不能单纯依赖通用的机器学习模型。

这里提到了有监督学习和无监督学习,提到了分类、回归、聚类等任务类型,我们需要简单科普一下这些概念。

机器学习中,有监督学习和无监督学习是两种主要的学习方法,它们有各自擅长的任务类型。

有监督学习是一种通过使用已标注的数据(即如前面图中的训练数据集那样,样本数据包含特征与对应的标签)来训练模型的方法。在这种方法中,每个训练样本都是一个输入-输出对,模型通过学习这些对的关系来预测新的输入数据的输出。有监督学习擅长的任务类型包括下面这几个:

  • 分类任务:将输入数据分类到预定义的类别中,例如垃圾邮件检测、图像分类。
  • 回归任务:预测连续的数值输出,例如房价预测(前面图中的示例)、股票价格预测。
  • 标注任务:为输入数据中的每个元素分配一个标签。例如:命名实体识别(NER):在文本中识别出人名、地名、组织名等。
  • 排序任务:根据某种标准对项目进行排序。例如:信息检索、推荐系统。
  • 序列预测任务:根据时间序列数据进行预测。例如,销售额预测、天气预报等。

使用有监督学习,我们需要向模型提供巨大数据集,且每个数据样本都需要包含特征和相应标签值,这很可能是一个既耗时又费钱的过程。

而无监督学习则是一种通过使用未标注的数据来训练模型的方法。在这种方法中,模型试图从数据中发现结构或模式,而无需使用明确的输入-输出对。无监督学习擅长的任务类型包括下面几个:

  • 聚类任务:将相似的样本归为一类,比如给定一组照片,模型能把它们分成风景照片、狗、婴儿、猫和山峰。同样,给定一组用户的网页浏览记录,模型能将具有相似行为的用户聚类。
  • 降维任务:减少数据的维度,同时保持其重要特征,例如主成分分析(PCA)问题,模型能否找到少量的参数来准确地捕捉数据的线性相关属性?比如,一个球的运动轨迹可以用球的速度、直径和质量来描述。
  • 异常检测:识别数据中的异常或异常模式,例如欺诈检测、设备故障检测。

这两种方法在不同的应用场景中各有所长,选择哪种方法通常取决于数据的特性和具体的任务需求。

我们以前面图中的房价预测问题为例,根据前面关于有监督和无监督的任务类型以及带有标签的数据对的训练数据集,我们初步判断应该选择线性回归模型。当然,你也可以自己探索数据集中一些特征与标签的关系,比如我们利用gonum.org/v1/plot相关包分别画出房屋面积、离市中心距离两个特征与标签房价的散点图(当然这是自己生成的一组训练数据集,具体描画代码参见https://github.com/bigwhite/experiments/blob/master/go-and-nn/linear-regression/plotter.go):

从数据的特征散点图,可以看出一些特征与标签之间的线性关系,这符合使用线性回归模型的要求。线性回归基于几个简单的假设:首先,假设自变量(x1, x2, x3, …, xn)和因变量y之间的关系是线性的,即y可以表示为自变量集合中元素的加权和。以前面的房价预测问题为例,线性模型对应的假设函数可以表示为居住面积、与市中心距离以及房龄的加权和,就像下面这样:

这个函数叫做假设函数(也叫预测函数),其中的w1、w2和w3称为权重,权重决定了每个特征对我们预测值的影响。b称为偏置(bias)、 偏移量(offset)或截距(intercept)。偏置是指当所有特征都取值为0时,预测值应该为多少。

现在权重w1、w2、w3和偏置b的值都是未知的,它们也被称为模型内的参数,直接影响模型的预测结果。

接下来的训练就是为了得到这些参数的合理值,使得假设函数得到的结果与真实房价越接近越好。

2.2.3 训练

到这里,我们拥有了一份训练数据集(带标签)以及一个权重和偏置参数未知的多元线性假设函数(y’)。而我们接下来要做的就是找到假设函数中各个未知参数的合理值。

机器学习的“学习训练”过程非常朴素,就是将训练数据集中的特征逐条喂给y’,并将得到的结果与训练数据集中的标签比对,如果差距过大,则调整y’的权重参数和偏置,然后再重复一轮学习,这样循环往复直到通过y’计算得到的结果与标签的差距在预期范围以内。

不过,这个过程看似容易,但真正实施起来,还有很多“阻塞点”要突破。以y’这个多元线性函数模型为例,首先就是权重和偏置参数的初始值。在我们这篇入门文章中,针对y’这个简单的线性函数,我们可采用随机初始化的方式,即将参数随机地设置在一个合理的范围内。这种方法简单快捷,但对于复杂的模型,可能会导致收敛速度慢或陷入局部最优。关于初始参数的选择也是一个细分方向,这里就不展开说明了。

其次,我们要确定一个y’计算结果与训练数据集中标签值的差距计算方法。机器学习领域称这个计算方法为损失函数(Loss function)。损失也就是 误差,也称为成本(cost)或代价,用于体现当前预测值和真实值之间的差距。它是一个数值,表示对于单个样本而言模型预测的准确程度。如果模型的预测完全准确,则损失为0;如果不准确,就有损失。在机器学习中,我们追求的当然是比较小的损失。不过,模型好不好还不能仅看单个样本,而是要针对所有数据样本,找到一组平均损失“较小”的函数模型。计算平均损失是每一个机器学习项目的必要环节。损失函数实质上就是用来计算平均损失的,它是模型参数的函数:L(w1, w2, w3, b)。机器学习的训练过程就是找一组模型参数的解,比如本示例中的(w1, w2, w3, b),使得损失函数的计算结果最小。

机器学习中的损失函数有很多,针对不同任务类别,选择一个合适的即可。

比如,用于回归的损失函数就有:均方误差(Mean Square Error,MSE)函数、平均绝对误差(Mean Absolute Error,MAE)函数和平均偏差误差(mean bias error)函数。用于分类的损失函数有交叉熵损失(cross-entropy loss)函数和多分类SVM损失(hinge loss)函数等。

对于我们的回归问题来说,下面的均方差函数L就可以满足评估参数的目的了。

在这个函数中,yi’基于样本数据的特征经由假设函数计算出来的值,yi则是样本数据的标签值。假设只有一个样本数据如下:

x1 = 55, x2 = 11, x3 = 5, y = 210

我们的假设函数为:y’ = 0.1×1 + 0.1×2+0.1×3 + 0.1 ,即初始参数w1 = w2 = w3 = b = 0.1。那么我们可以计算一下针对这个样本的损失:

y' = 0.1 * 55 + 0.1 * 11 + 0.1 * 5 + 0.1 = 7.2
L = 1/2 * (7.2 - 210)^2 = 20563.920000000002

这个损失函数值看起来就不大行:),我们需要调整模型参数再战!但如何调整呢?w1调大?w2调小?w3不动?尽管现在算力已经很强大了,但我们也不能拍脑袋乱猜!我们需要一种科学的方法为机器学习后续的参数调整指明方向,这样才能大幅缩短训练过程,并得到满足需求的模型参数组合。

大多流行的优化算法通常基于一种基本方法–梯度下降(gradient descent)。简而言之,在每个步骤中,梯度下降法都会检查每个参数,看看如果仅对该参数进行少量变动,训练集损失会朝哪个方向移动。然后,它在可以减少损失的方向上优化参数。

梯度下降的过程就是在程序中一点点变化参数w1、w2、w3和b,使L ,也就是损失值逐渐趋近最低点(也称为机器学习中的最优解)。而要实现这一点,我们需要借助导数。导数描述了函数在某点附近的变化率(比如:L正在随着w1增大而增大还是减小),而这正是进一步猜测更好的权重时所需要的全部内容。即梯度下降法通过求导来计算损失曲线在起点处的梯度。此时,梯度就是损失曲线导数的矢量,它可以让我们了解哪个方向距离目标“更近”或“更远”。如果求导后梯度为正值,则说明L正在随着w增大而增大,应该减小w,以得到更小的损失。如果求导后梯度为负值,则说明L正在随着w增大而减小,应该增大w,以得到更小的损失。

在单个权重参数的情况下,损失相对于权重的梯度就称为导数;若考虑偏置,或存在多个权重参数时(就像我们上面的房价预测示例),损失相对于单个权重的梯度就称为偏导数。

在上面示例中,损失函数L是权重参数和偏置的函数,表示为L(w1, w2, w3, b)。我们需要分别求出L相对于w1、w2、w3和b的偏导数来决定后续各个权重参数和偏置参数的调整方向(增大还是减小)。我们以L对w1的偏导数为例,给出偏导数公式的推导过程:

我们看到:针对每个样本,我们计算其损失值(y’-y)与该样本特征(x1)的乘积。取这些乘积的平均值就得到了L对w1的偏导值。

依次类推,我们可以得到L对w1、w2、w3和b的偏导数公式:

上面的偏导数为我们指定了参数调整方向,下面是w1、w2、w3和b的更新公式:

这种计算梯度并反向更新模型参数的过程就称为“反向传播”。

上面参数更新公式中有一个新的变量α,该变量代表的是学习率(learning rate)。是一个超参数,它控制着模型参数更新的步伐大小。在梯度下降过程中,学习率决定了每次更新参数时移动的步长。学习率的引入是为了控制模型训练的速度。如果学习率太大,参数更新步伐过大,可能导致模型无法收敛甚至发散;如果学习率太小,参数更新步伐过小,训练时间会过长且可能陷入局部最小值。

w1的更新公式是w1减去损失函数相对于w1的偏导数乘以学习率,这个公式表示,我们沿着损失函数梯度的负方向更新参数,因为梯度的方向是损失函数增大的方向,所以负方向是使损失函数减小的方向。

到这里,我们已经可以实现训练的闭环了!训练后的模型可以使用另外一套测试数据集来评估模型的效果。但训练出来的模型是否真的是满足要求的呢?还不一定,很多情况下,我们还需要对超参进行调试以继续优化模型。

2.2.4 超参调试与性能优化

在上面的讲解中,我们知道w1、w2、w3和b是模型内的参数,这些参数通过y’正向传播和基于梯度下降的反向传播在多轮训练中得以更新优化,并得到一个合理的值。这些值是机器从数据中学习到的,不需要我们手工调整。但还有一些参数,比如上面提到的学习率(learning rate)、训练轮数(Epochs)等,是模型外部的可以通过人工调节的参数,这样的参数称为超参数(Hyperparameters)。大多数机器学习从业者真正花费相当多的时间来调试的正是这类超参数。

在实际应用中,选择合适的学习率和训练轮数等超参数通常需要结合以下方法:

  • 经验法则:基于先前经验和领域知识设定初始值。
  • 交叉验证:通过交叉验证选择一组最优的超参数。
  • 网格搜索:在多个可能的超参数组合上进行搜索,找到效果最好的参数组合。
  • 学习率调度:动态调整学习率,比如在训练过程中逐渐减小学习率。

超参数对模型效果和优化的影响非常重要,选择合适的超参数可以显著提高模型性能。本文是入门文章,关于超参的调优就不展开说明了。

基于通过上面的对机器学习的术语、概念和对训练一般步骤的了解,接下来,我们通过一个实例来训练一个最简单的机器学习模型:线性回归模型。这也被称为机器学习领域的“Hello, World”。

3. 线性回归:机器学习的Hello, World

我们按照前面关于机器学习的一般步骤,逐步展开该示例的说明。

3.1 准备数据和预处理

我们这个示例依旧是预测房价,但是为了简单,我们不使用那些公共数据集(比如kaggle平台上的数据),而是让大模型帮我生成两个小规模的数据集,一个是用于训练的train.csv,一个是用于测试的test.csv:

$cat train.csv
面积,距离,房价
50,10,200
60,12,220
70,15,250
80,20,300
90,25,330
100,30,360
110,35,390
120,40,420
130,45,450
140,50,480

$cat test.csv
面积,距离,房价
55,11,210
65,13,230
75,17,260
85,22,310
95,27,340
105,32,370
115,37,400
125,42,430
135,47,460
145,52,490

还是为了简单,我们在这两份数据集中仅使用两个特征:面积和离市中心距离。

接下来,我们就通过编码来实现对csv文件的读取:

// go-and-nn/linear-regression/main.go
func readCSV(filePath string) ([][]float64, error) {
    file, err := os.Open(filePath)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    reader := csv.NewReader(file)
    records, err := reader.ReadAll()
    if err != nil {
        return nil, err
    }

    data := make([][]float64, len(records)-1)
    for i := 1; i < len(records); i++ {
        data[i-1] = make([]float64, len(records[i]))
        for j := range records[i] {
            data[i-1][j], err = strconv.ParseFloat(records[i][j], 64)
            if err != nil {
                return nil, err
            }
        }
    }
    return data, nil
}

readCSV用于从CSV文件中读取所有样本数据(已去掉了header),所有样本数据(包括特征与标签)都存储在一个[][]float64类型的变量中。

拿到数据后,我们便可以对其进行标准化,前面说过通常情况下,标准化后的数据会使模型训练更加稳定和快速,从而可能提高模型的预测性能。下面是我们实现用于对训练数据集进行标准化的函数:

// go-and-nn/linear-regression/main.go
func standardize(data [][]float64) ([][]float64, []float64, []float64) {
    mean := make([]float64, len(data[0])-1)
    std := make([]float64, len(data[0])-1)
    for i := 0; i < len(data[0])-1; i++ {
        for j := 0; j < len(data); j++ {
            mean[i] += data[j][i]
        }
        mean[i] /= float64(len(data))
    }
    for i := 0; i < len(data[0])-1; i++ {
        for j := 0; j < len(data); j++ {
            std[i] += math.Pow(data[j][i]-mean[i], 2)
        }
        std[i] = math.Sqrt(std[i] / float64(len(data)))
    }
    standardizedData := make([][]float64, len(data))
    for i := 0; i < len(data); i++ {
        standardizedData[i] = make([]float64, len(data[i]))
        for j := 0; j < len(data[i])-1; j++ {
            standardizedData[i][j] = (data[i][j] - mean[j]) / std[j]
        }
        standardizedData[i][len(data[i])-1] = data[i][len(data[i])-1]
    }
    return standardizedData, mean, std
}

standardize中的mean和std分别用于存储每个特征的均值和标准差(标准差是反应一组数据离散程度最常用的一种量化形式,累加每个样本的特征值与均值的平方差,然后除以样本数量,再开平方,便可得到该特征的标准差)。有了均值和标准差后,我们用原始特征值减去均值,然后除以标准差,得到标准化后的特征值。标签无需标准化。

3.2 选择机器学习模型

基于前面的铺垫,我们早就明确了适合房屋价格预测的机器学习模型,那就是一个多元线性函数,确定假设函数为:

损失函数我们也用均方误差(Mean Square Error,MSE)函数,这样损失函数就是w1、w2和b的函数:L(w1, w2, b)。依据前面的介绍,我们可以推导出损失函数L对w1、w2和b的偏导数以及权重更新公式如下:

确定了模型相关的公式后,我们就可以来实现该模型的训练了!

3.3 训练

下面是训练函数的实现代码:

// go-and-nn/linear-regression/main.go
func trainModel(data [][]float64, learningRate float64, epochs int) ([]float64, float64) {
    features := len(data[0]) - 1
    weights := make([]float64, features)
    bias := 0.0

    for epoch := 0; epoch < epochs; epoch++ {
        gradW := make([]float64, features)
        gradB := 0.0
        mse := 0.0
        for i := 0; i < len(data); i++ {
            prediction := bias
            for j := 0; j < features; j++ {
                prediction += weights[j] * data[i][j]
            }
            error := prediction - data[i][features]
            mse += error * error
            for j := 0; j < features; j++ {
                gradW[j] += error * data[i][j]
            }
            gradB += error
        }
        mse /= float64(len(data))

        // 更新权重
        for j := 0; j < features; j++ {
            gradW[j] /= float64(len(data))
            weights[j] -= learningRate * gradW[j]
        }   

        gradB /= float64(len(data))
        // 更新偏置
        bias -= learningRate * gradB

        // Output the current weights, bias and loss
        fmt.Printf("Epoch %d: Weights: %v, Bias: %f, MSE: %f\n", epoch+1, weights, bias, mse)
    }
    return weights, bias
}

在这个代码实现中,我们将权重和偏置的初始值都设置为了0,然后进入训练循环,循环的次数由外部传入的epochs来决定,前面提到过epochs也是一个超参。每次循环代表一次完整的训练过程。gradW用于存储每个特征的梯度,gradB则用于存储偏置的梯度值。梯度计算以及后面的更新权重的算法也都是按照上面图片中的公式进行的。注意代码中的error变量并非代表错误,而是表示预测误差(即预测值减去真实标签值)。

下面是驱动训练函数的代码:

// go-and-nn/linear-regression/main.go
func main() {
    // Read training data
    trainData, err := readCSV("train.csv")
    if err != nil {
        log.Fatalf("failed to read training data: %v", err)
    }

    // Read testing data
    testData, err := readCSV("test.csv")
    if err != nil {
        log.Fatalf("failed to read testing data: %v", err)
    }

    // Standardize training data
    standardizedTrainData, mean, std := standardize(trainData)

    // Train model
    learningRate := 0.0001
    epochs := 1000
    weights, bias := trainModel(standardizedTrainData, learningRate, epochs)
    fmt.Printf("Trained Weights: %v\n", weights)
    fmt.Printf("Trained Bias: %f\n", bias)

    // Evaluate model on test data
    predictAndEvaluate2(testData, weights, bias, mean, std)
}

这里我们设置超参学习率为0.0001,设置epochs为1000,即进行1000轮完整的训练。trainModel训练完成后返回最优的权重值和偏置值。

之后,我们基于训练后的模型以及测试数据集进行模型效果评估,

// go-and-nn/linear-regression/main.go
func predictAndEvaluate(data [][]float64, weights []float64, bias float64, mean []float64, std []float64) {
    features := len(data[0]) - 1
    mse := 0.0
    for i := 0; i < len(data); i++ {
        // Standardize the input features using the training mean and std
        standardizedFeatures := make([]float64, features)
        for j := 0; j < features; j++ {
            standardizedFeatures[j] = (data[i][j] - mean[j]) / std[j]
        }

        // Calculate the prediction
        prediction := bias
        for j := 0; j < features; j++ {
            prediction += weights[j] * standardizedFeatures[j]
        }

        // Calculate the error and accumulate the MSE
        error := prediction - data[i][features]
        mse += error * error

        // Print the prediction and the actual value
        fmt.Printf("Sample %d: Predicted Value: %f, Actual Value: %f\n", i+1, prediction, data[i][features])
    }

    // Calculate the final MSE
    mse /= float64(len(data))
    fmt.Printf("Mean Squared Error: %f\n", mse)
}

该评估函数会输出测试集中每一组数据的预测值与标签值的对比。

我们运行一下该代码:

$go build
$./demo
Epoch 1: Weights: [0.009191300234460844 0.009159461537409297], Bias: 0.034000, MSE: 124080.000000
Epoch 2: Weights: [0.018380768863390594 0.01831709148135162], Bias: 0.067997, MSE: 124053.513977
Epoch 3: Weights: [0.02756840625241513 0.027472890197452842], Bias: 0.101990, MSE: 124027.033923
Epoch 4: Weights: [0.03675421276708735 0.036626858051265865], Bias: 0.135980, MSE: 124000.559834
Epoch 5: Weights: [0.04593818877288719 0.0457789954082706], Bias: 0.169966, MSE: 123974.091710
... ...
Epoch 997: Weights: [8.311660331200889 8.279923139396109], Bias: 32.264505, MSE: 100432.407457
Epoch 998: Weights: [8.319195610465172 8.287426591989], Bias: 32.295278, MSE: 100411.202067
Epoch 999: Weights: [8.326729388699432 8.294928543563927], Bias: 32.326049, MSE: 100390.001368
Epoch 1000: Weights: [8.334261666203304 8.302428994420524], Bias: 32.356816, MSE: 100368.805359
Trained Weights: [8.334261666203304 8.302428994420524]
Trained Bias: 32.356816
Sample 1: Predicted Value: 10.081607, Actual Value: 210.000000
Sample 2: Predicted Value: 14.223776, Actual Value: 230.000000
Sample 3: Predicted Value: 19.606495, Actual Value: 260.000000
Sample 4: Predicted Value: 25.609490, Actual Value: 310.000000
Sample 5: Predicted Value: 31.612486, Actual Value: 340.000000
Sample 6: Predicted Value: 37.615481, Actual Value: 370.000000
Sample 7: Predicted Value: 43.618476, Actual Value: 400.000000
Sample 8: Predicted Value: 49.621471, Actual Value: 430.000000
Sample 9: Predicted Value: 55.624466, Actual Value: 460.000000
Sample 10: Predicted Value: 61.627461, Actual Value: 490.000000
Mean Squared Error: 104949.429046

从最终的预测结果输出来看,这个模型的效果那是相当的差!预测值与测试集中的真实标签值相距“十万八千里”!问题出在哪里了呢?我们接下来来看看超参对模型训练的作用。

3.4 超参调试和优化

我们在上面例子中使用的学习率(learningRate)为0.0001,这个数值似乎有些小。

如果学习率太小,模型的更新幅度会很小,导致训练过程非常缓慢,可能需要大量的训练轮次才能收敛。我们这里设置的训练轮次(epochs)为1000,在0.0001如此小的学习率下面,模型可能尚未收敛,训练就结束了!所以,我们尝试先将学习率由0.0001改为0.01,再来训练和评估一次,这回的输出结果如下:

$go build
$./demo
Epoch 1: Weights: [0.009191300234460844 0.009159461537409297], Bias: 0.034000, MSE: 124080.000000
Epoch 2: Weights: [0.018380768863390594 0.01831709148135162], Bias: 0.067997, MSE: 124053.513977
Epoch 3: Weights: [0.02756840625241513 0.027472890197452842], Bias: 0.101990, MSE: 124027.033923
Epoch 4: Weights: [0.03675421276708735 0.036626858051265865], Bias: 0.135980, MSE: 124000.559834
Epoch 5: Weights: [0.04593818877288719 0.0457789954082706], Bias: 0.169966, MSE: 123974.091710
Epoch 6: Weights: [0.055120334635221604 0.05492930263387402], Bias: 0.203949, MSE: 123947.629550
...  ...
Epoch 996: Weights: [47.520035679041236 44.407936879025506], Bias: 339.984720, MSE: 44.287037
Epoch 997: Weights: [47.521568654779436 44.406403906767075], Bias: 339.984872, MSE: 44.286092
Epoch 998: Weights: [47.523101572396406 44.404870992560404], Bias: 339.985024, MSE: 44.285147
Epoch 999: Weights: [47.524634431895045 44.40333813640399], Bias: 339.985174, MSE: 44.284203
Epoch 1000: Weights: [47.52616723327823 44.401805338296306], Bias: 339.985322, MSE: 44.283259
Trained Weights: [47.52616723327823 44.401805338296306]
Trained Bias: 339.985322
Sample 1: Predicted Value: 216.742422, Actual Value: 210.000000
Sample 2: Predicted Value: 239.923439, Actual Value: 230.000000
Sample 3: Predicted Value: 269.738984, Actual Value: 260.000000
Sample 4: Predicted Value: 302.871794, Actual Value: 310.000000
Sample 5: Predicted Value: 336.004604, Actual Value: 340.000000
Sample 6: Predicted Value: 369.137414, Actual Value: 370.000000
Sample 7: Predicted Value: 402.270225, Actual Value: 400.000000
Sample 8: Predicted Value: 435.403035, Actual Value: 430.000000
Sample 9: Predicted Value: 468.535845, Actual Value: 460.000000
Sample 10: Predicted Value: 501.668655, Actual Value: 490.000000
Mean Squared Error: 54.966611

这回我们看懂,训练后的模型在测试集上的预测结果与实际标签值非常接近,可以看到对超参learningRate的调整见效了!

当然如果不调整learningRate,通过调节epochs到一个更大的值可能也能达到这个效果,但却要耗费更多的算力和等待时间。

4. 小结

本文是我在去年发表了与机器学习相关的文章《Go与神经网络:张量运算》之后的又一篇尝试。在这篇文章中,我从最基础的机器学习入手,以线性回归这个传统机器学习中的”Hello, World”示例为切入点,逐步探讨机器学习的基本概念和实现流程。

在这篇文章中,我们在解决线性回归问题时并未引入神经网络的概念,其实基于神经网络也可以解决线性回归问题,并且一个线性回归模型可以看成是一个单层的全连接神经网络。在后续的文章中,我们会使用神经网络再解线性回归问题,到时候本文的知识也会帮助你更好地理解神经网络。

本文涉及的源码可以在这里下载 – https://github.com/bigwhite/experiments/blob/master/go-and-nn/linear-regression

本文的数学公式均由https://www.latexlive.com/基于latex语法在线生成。

本文中的部分源码由OpenAI的GPT-4o生成。

5. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Gopher的Rust第一课:Rust代码组织

本文永久链接 – https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code

在上一章的讲解中,我们编写了第一个Rust示例程序”hello, world”,并给出了rustc版和cargo版本。在真实开发中,我们都会使用cargo来创建和管理Rust包。不过,Hello, world示例非常简单,仅仅由一个Rust源码文件组成,而且所有源码文件都在同一个目录中。但真实世界中的实用Rust程序,无论是公司商业项目,还是一些知名的开源项目,甚至是一些稍复杂一些的供教学使用的示例程序,它们通常可不会这么简单,都有着复杂的代码结构。

Rust初学者在阅读这些项目源码时便仿佛进入了迷宫,不知道该走哪条(阅读代码的)路径,不知道每个目录代表的含义,也不知道自己想看的源码究竟在哪个目录下。但目前市面上的Rust入门教程大多没有重视初学者的这一问题,要么没有对Rust项目代码组织结构进行针对性的讲解,要么是将讲解放到书籍的后面章节。

根据我个人的学习经验来看,理解一个实用Rust项目的代码组织结构越早,对后续的Rust学习越有益处。同时,掌握Rust项目的代码组织结构也是Rust开发者走向编写复杂Rust程序的必经的一步。并且,初学者在了解项目的代码组织结构后,便可以自主阅读一些复杂的Rust项目的源码,可提高Rust学习的效率,提升学习效果。因此,我决定在介绍Rust基础语法之前先在本章中系统地介绍Rust的代码组织结构,以满足很多Rust初学者的述求。

但在介绍Rust代码组织结构之前,我们需要先来系统说明一下Rust代码组织结构中的几个重要概念,它们是了解Rust项目代码组织结构的前提。

4.1 回顾Go代码组织

Go项目代码组织由module和package两级组成。通常来说,每个Go repo就是一个module,由repo根目录下的go.mod定义,go.mod文件所在目录也被称为module root。go.mod中典型内容如下:

// go.mod
module github.com/user/mymodule[/vN]

go 1.22.1

... ...

go.mod中的module directive一行后面的github.com/user/mymodule/[vN]是module path。module path一来可以反映该module的具体网络位置,同时也是该module下面的Go package导入(import)路径的组成部分。module root下的子目录中通常存放着该module下面的Go package,比如module root/foo目录下存放的Go包的导入路径为github.com/user/mymodule[/vN]/foo。

Go package是Go的编译单元,也是功能单元,代码内外部导入和引用的单位也都是包。而go module是后加入的,更多用于管理包的版本(一个module下的所有包都统一进行版本管理)以及构建时第三方依赖和版本的管理。

更多关于Go module和package管理以及Go项目布局的内容,可以详见我的极客时间《Go语言第一课》专栏。

个人认为Go的module和package的两级管理还是很好理解和管理的,在这方面Rust的代码组织形式又是怎样的呢?接下来,我们就来正式看看Rust的代码组织。

4.2 rustc-only的Rust项目

Rust是系统编程语言,这让我想起了当初在Go成为我个人主力语言之前使用C/C++进行开发的岁月。C/C++是没有像go或Rust的cargo那样的统一的包依赖管理器和项目构建管理工具的。编译器(如gcc等)是核心工具,而项目构建管理则经常由其他工具负责,如Makefile、CMake,或者是Google的Bazel等。在Windows上开发应用的,则往往使用微软或其他开发者工具公司提供的IDE,如当年炙手可热的Visual Studio系列。

下面表格展示了各语言的编译器/链接器和构建管理工具的关系:

像cargo、go这样的“一站式”工具链都旨在为开发者提供体验更为友好的交互接口的,在幕后,它们仍然依赖于底层的编译器和链接器(如rustc和go tool compile/link)来执行实际的代码编译。

不过,像cargo这样的高级工具也给开发人员带来了额外的抽象,或是叫“掩盖”了一些真相,这有时候让人看不清构建过程的本质,比如:很多Gopher用了很多年Go,但却不知道go tool compile/link的存在。

本着只有in hard way,才能看到和抓住本质的思路,以及之前学习用系统编程语言C/C++时经验,这里我们先来看一些rustc-only的Rust项目。Rustc-only的Rust项目是指不使用Cargo创建和管理的Rust项目,而是直接使用rustc编译器来编译和构建项目。这意味着开发者需要编写自己的构建脚本,例如使用Makefile或其他构建工具来管理项目的构建过程。

不过,请注意:这类项目极少用于生产,即便是那些不需要复杂的依赖管理的小型项目。这里使用rustc-only的Rust项目仅仅是为了学习和了解Rustc编译器的主要功能机制以及Rust语言在代码组织上的一些抽象,比如module等。

下面我们就从最简单的rustc-only项目开始,先来看看只有一个Rust源文件且无其他依赖项的“最简项目”。

4.2.1 单文件项目

所谓单文件项目,即只有一个Rust源文件,例如前面章节中的hello_world.rs,这种项目可以直接使用rustc编译器来编译和运行:

// rust-guide-for-gopher/organizing-rust-code/rustc-only/single/hello-world/hello_world.rs
fn main() {
    println!("Hello, world!");
}

对于顶层带有main函数的源文件,rustc会默认将其视为binary crate类型的源文件,并将其编译为可执行二进制文件hello_world。

我们当然也可以强制的让rustc将该源文件视为library crate类型的源文件,并将其编译为其他类型的crate输出文件,rustc支持多种crate type:

      --crate-type [bin|lib|rlib|dylib|cdylib|staticlib|proc-macro]
                        Comma separated list of types of crates
                        for the compiler to emit

rustc的文档中,各种crate类型的含义如下:

lib — Generates a library kind preferred by the compiler, currently defaults to rlib.
rlib — A Rust static library.
staticlib — A native static library.
dylib — A Rust dynamic library.
cdylib — A native dynamic library.
bin — A runnable executable program.
proc-macro — Generates a format suitable for a procedural macro library that may be loaded by the compiler.

不过,如果强制将带有顶层main函数的rust源文件视为lib crate型的,那么rustc将会报warning,提醒你函数main将是死代码,永远不会被用到:

$rustc --crate-type lib hello_world.rs
warning: function `main` is never used
 --> hello_world.rs:1:4
  |
1 | fn main() {
  |    ^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: 1 warning emitted

但即便如此,一个名为libhello_world.rlib的文件依然会被rustc生成出来!(目前–crate-type lib等同于–create-type rlib)。

4.2.2 有外部依赖项的单文件项目

日常开发中,像上面的Hello, World级别的trivial应用是极其少见的,一个non-trivial的Rust应用或多或少都会有一些依赖。这里我们也来看一下如何基于rustc来构建带有外部依赖的单文件项目。下面是一个带有外部依赖的示例:

// organizing-rust-code/rustc-only/single/hello-world-with-deps/hello_world.rs
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
}

这个示例程序依赖一个名为rand的crate,要编译该程序,我们必须先手动下载rand的crate源码,并在本地将rand源码编译为示例程序所需的rust library。下面步骤展示了如何下载和构建rand crate:

$curl -LO https://crates.io/api/v1/crates/rand/0.8.5/download
$tar -xvf download

解压后,我们将看到rand-0.8.5这样的一个crate目录,进入该目录,我们执行cargo build来构建rand crate:

$cd rand-0.8.5
$cargo build
... ...
   Finished dev [unoptimized + debuginfo] target(s) in 0.19s

cargo构建出的librand.rlib就在rand-0.8.5/target/debug下。

注:rlib的命名方式:lib+{crate_name}.rlib

接下来,我们就来构建一下依赖rand crate的hello_world.rs:

// 在organizing-rust-code/rustc-only/single/hello-world-with-deps下面执行

$rustc --verbose  -L ./rand-0.8.5/target/debug  --extern rand=librand.rlib hello_world.rs
error[E0463]: can't find crate for `rand_core` which `rand` depends on
 --> hello_world.rs:1:1
  |
1 | extern crate rand;
  | ^^^^^^^^^^^^^^^^^^ can't find crate

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0463`.

我们看到rustc的编译错误提示:无法找到rand crate依赖的rand_core crate!也就是说我们除了向rustc提供hello_world.rs依赖的rand crate之外,还要向rustc提供rand crate的各种依赖!

rand crate的各种依赖在哪里呢?我们在构建rand crate时,cargo build将各种依赖都放在了rand-0.8.5/target/debug/deps目录下了:

$ls -l|grep ".rlib"
-rw-r--r--   1 tonybai  staff     6896  4 29 06:45 libcfg_if-cd6bebf18fb9c234.rlib
-rw-r--r--   1 tonybai  staff   204072  4 29 06:45 libgetrandom-df6a8e95e188fc56.rlib
-rw-r--r--   1 tonybai  staff  1651320  4 29 06:45 liblibc-f16531562d07b476.rlib
-rw-r--r--   1 tonybai  staff   959408  4 29 06:45 libppv_lite86-f1d97d485bc43617.rlib
-rw-r--r--   1 tonybai  staff  1784376  4 29 06:45 librand-9a91ea8db926e840.rlib
-rw-r--r--   1 tonybai  staff   987936  4 29 06:45 librand_chacha-6fe22bd8b3bb228c.rlib
-rw-r--r--   1 tonybai  staff   256768  4 29 06:45 librand_core-fc905f6ca5f8533b.rlib

我们看到其中还包含了librand自身:librand-9a91ea8db926e840.rlib。我们来试试基于deps目录下的这些依赖rlib编译一下:

$rustc --verbose  --extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib -L rand-0.8.5/target/debug/deps  --extern rand_core=librand_core-fc905f6ca5f8533b.rlib --extern getrandom=libgetrandom-df6a8e95e188fc56.rlib --extern cfg_if=libcfg_if-cd6bebf18fb9c234.rlib --extern libc=liblibc-f16531562d07b476.rlib --extern rand_chacha=librand_chacha-6fe22bd8b3bb228c.rlib --extern ppv_lite86=libppv_lite86-f1d97d485bc43617.rlib  hello_world.rs

我们用rustc成功编译了带有外部依赖的Rust源码。不过这里要注意的是rustc对直接依赖和间接依赖的crate的定位方式有所不同。

对于直接依赖的crate,比如这里的rand crate,我们需要给出具体路径,它不依赖-L的位置指示,所以这里我们使用了–extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib。

对于间接依赖的crate,比如rand crate依赖的rand_core,rust会结合-L指示的位置以及–extern一起来定位,这里-L指示路径为rand-0.8.5/target/debug/deps,–extern rand_core=librand_core-fc905f6ca5f8533b.rlib,那么rustc就会在rand-0.8.5/target/debug/deps下面搜索librand_core-fc905f6ca5f8533b.rlib是否存在。

我们运行rustc构建出的可执行文件,输出如下:

$./hello_world
Random number: 431751199

4.2.3 有外部依赖的多文件项目

在Go中,如果某个目录下有多个源文件,那么通常这几个源文件均归属于同一个Go包(可能的例外的是*_test.go文件的包名)。但在Rust中,情况就会变得复杂了一些,我们来看一个例子:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps

$tree -F -L 2
.
├── main.rs
├── sub1/
│   ├── bar.rs
│   ├── foo.rs
│   └── mod.rs
└── sub2.rs

在这个示例中,我们看到除了main.rs之外,还有一个sub2.rs以及一个目录sub1,sub1下面还有三个rs文件。我们从main.rs开始,逐一看一下各个源文件的内容:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/main.rs
 1 extern crate rand;
 2 use rand::Rng;
 3
 4 mod sub1;
 5 mod sub2;
 6
 7 mod sub3 {
 8     pub fn func1() {
 9         println!("called {}::func1()", module_path!());
10     }
11     pub fn func2() {
12         self::func1();
13         println!("called {}::func2()", module_path!());
14         super::func1();
15     }
16 }
17
18 fn func1() {
19     println!("called {}::func1()", module_path!());
20 }
21
22 fn main() {
23     println!("current module: {}", module_path!());
24     let mut rng = rand::thread_rng();
25     let num: u32 = rng.gen();
26     println!("Random number: {}", num);
27
28     sub1::func1();
29     sub2::func1();
30     sub3::func2();
31 }

在main.rs中,我们除了看到了第1~2行的对外部rand crate的依赖外,我们还看到了一种新的语法元素:rust module。这里涉及sub1~sub3三个module,我们分别来看一下。先来看一下最直观的、定义在main.rs中的sub3 module。

第7行~第16行的代码定义了一个名为sub3的module,它包含两个函数func1和func2,这两个函数前面的pub关键字表明他们是sub3 module的publish函数,可以被module之外的代码所访问。任何未标记为pub的函数都是私有的,只能在模块内部及其子模块中使用。

在sub3 module的func2函数中,我们调用了self::func1()函数,self指代是模块自身,因此这个self::func1()函数就是sub3的func1函数。而接下来调用的super::func1()调用的语义你大概也能猜到。super指代的是sub3的父模块,而super::func1()就是sub3的父模块中的func1函数。

sub3的父模块就是这个项目的顶层模块,我们在main函数的入口处使用module_path!宏输出了该顶层模块的名称。

和sub3在main.rs中定义不同,sub1和sub2也分别代表了另外两种module的定义方式。

当Rust编译器看到第4行mod sub1后,它会寻找当前目录下是否有名为sub1.rs的源文件或是sub1/mod.rs源文件。在这个示例中,sub1定义在sub1目录下的mod.rs中:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/mod.rs

pub mod bar;
pub mod foo;

pub fn func1() {
    println!("called {}::func1()", module_path!());
    foo::func1();
    bar::func1();
}

我们看到sub1/mod.rs中定义了一个公共函数func1,同时也在最开始处又嵌套定义了bar和foo两个module,并在func1中调用了两个嵌套子module的函数:

bar和foo两个module都是使用单文件module定义的,编译器会在sub1目录下搜寻foo.rs和bar.rs:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/foo.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/bar.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}

而main.rs中的sub2也是一个单文件的module,其源码位于顶层目录下的sub2.rs文件中:

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub2.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}

现在我们来编译和执行一下这个既有外部依赖,又是多文件且有多个module的rustc-only项目:

$rustc --verbose  --extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib -L rand-0.8.5/target/debug/deps  --extern rand_core=librand_core-fc905f6ca5f8533b.rlib --extern getrandom=libgetrandom-df6a8e95e188fc56.rlib --extern cfg_if=libcfg_if-cd6bebf18fb9c234.rlib --extern libc=liblibc-f16531562d07b476.rlib --extern rand_chacha=librand_chacha-6fe22bd8b3bb228c.rlib --extern ppv_lite86=libppv_lite86-f1d97d485bc43617.rlib  main.rs 

$./main
current module: main
Random number: 2691905579
called main::sub1::func1()
called main::sub1::foo::func1()
called main::sub1::bar::func1()
called main::sub2::func1()
called main::sub3::func1()
called main::sub3::func2()
called main::func1()

上面示例演示了三种rust module的定义方法:

  1. 直接将定义嵌入在某个rust源文件中:
mod module_name {

}
  1. 通过module_name.rs
  2. 通过module_name/mod.rs

在一个单crate的项目中,通过rust module可以满足项目内部代码组织的需要。

最后,我们再来看一个有多个crate的项目形式。

4.2.4 有多个crate的项目

下面是一个有着多个crate项目的示例:

// organizing-rust-code/rustc-only/workspace

$tree -L 2 -F
.
├── main.rs
├── my_local_crate1/
│   └── lib.rs
└── my_local_crate2/
    └── lib.rs

在这个示例中有三个crate,一个是顶层的binary类型的crate,入口为main.rs,另外两个都是lib类型的crate,入口都在lib.rs中,我们贴一下他们的源码:

// organizing-rust-code/rustc-only/workspace/main.rs
extern crate my_local_crate1;
extern crate my_local_crate2;

fn main() {
    let x = 5;
    let y = my_local_crate1::add_one(x);
    let z = my_local_crate2::multiply_two(y);
    println!("Result: {}", z);
}

// organizing-rust-code/rustc-only/workspace/my_local_crate1/lib.rs
pub fn add_one(x: i32) -> i32 {
    x + 1
}

// organizing-rust-code/rustc-only/workspace/my_local_crate2/lib.rs
pub fn multiply_two(x: i32) -> i32 {
    x * 2
}

要构建这个带有三个crate的项目,我们需要首先编译my_local_crate1和my_local_crate2这两个lib crates:

$rustc --crate-type lib --crate-name my_local_crate1 my_local_crate1/lib.rs
$rustc --crate-type lib --crate-name my_local_crate2 my_local_crate2/lib.rs

这会在项目顶层目录下生成两个rlib文件:

$ls  |grep rlib
libmy_local_crate1.rlib
libmy_local_crate2.rlib

之后,我们就可以用之前学到的方法编译binary crate了:

$rustc --extern my_local_crate1=libmy_local_crate1.rlib --extern my_local_crate2=libmy_local_crate2.rlib main.rs

上述的几个rustc-only的rust项目都是hard模式的,即一切都需要手工去做,包括下载crate、编译crate时传入各种路径等。在真正的生产中,Rustacean们是不会这么做的,而是会直接使用cargo对rust项目进行管理。接下来,我们就来系统地看一下使用cargo进行rust项目管理以及对应的rust代码组织形式。

4.3 使用cargo管理的Rust项目

在前面的章节中,我们见识过了:Rust的包管理器Cargo是一个强大的工具,可以帮助我们轻松地管理Rust项目,cargo才是生产类项目的项目构建管理工具标准,它可以让Rustacean避免复杂的手工rustc操作。Cargo提供了许多功能,包括依赖项管理、构建和测试等。不过在这篇文章中,我不会介绍这些功能,而是看看使用cargo管理的Rust项目都有哪些代码组织模式。

Rust项目的代码组织结构可以分为两类:单一package和多个package。

什么是package?在之前的rust-only项目中,我们可从未见到过package!package是cargo引入的一个管理单元概念,它指的是一个独立的Rust项目,包含了源代码、依赖项和配置信息。每个Package都有一个唯一的名称和版本号,用于标识和管理项目。因此,在the cargo book中,cargo也被称为“Rust package manager”,crates.io也被称为“the Rust community’s package registry”。

最能直观体现package存在的就是下面Cargo.toml中的配置了:

[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]

下面我们就来看看不同类型的rust package的代码组织形式。我们先从单一package形态的项目来开始。

4.3.1 单一package的rust项目

单一package项目是指整个项目只有一个Cargo.toml文件。这种项目还可以进一步分为三类:

  1. 单一Binary Crate
  2. 单一Library Crate
  3. 多个Binary Crate和一个Library Crate

下面我们分别举例来说明一下这三类项目。

4.3.1.1 单一Binary Crate

我们进入organizing-rust-code/cargo/single-package/single-binary-crate,然后执行下面命令来创建一个单一Binary Crate的项目:

$cargo new hello_world --bin
     Created binary (application) `hello_world` package

这个例子我们在之前的章节中也是见过的,它的结构如下:

$tree hello_world
hello_world
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

默认生成的Cargo.toml内容如下:

[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

使用cargo build即可完成该项目的构建:

$cargo build
   Compiling hello_world v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/single-package/single-binary-crate/hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 1.16s

为了更显式地体现这是一个binary crate,我们可以在Cargo.toml增加如下内容:

[[bin]]
name = "hello_world"
path = "src/main.rs"

这不会影响cargo的构建结果!

通过cargo run可以查看构建出的可执行文件的运行结果:

$cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/hello_world`
Hello, world!

接下来,我们再来看看单一library crate的rust项目。

4.3.1.2 单一Library Crate

我们进入organizing-rust-code/cargo/single-package/single-library-crate,然后执行下面命令来创建一个单一Library Crate的项目:

$cargo new my_library --lib
     Created library `my_library` package

创建后的my_library项目的结构如下:

$tree
.
├── Cargo.toml
└── src
    └── lib.rs

默认生成的Cargo.toml如下:

[package]
name = "my_library"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

和binary crate的一样,我们也可以显式指定target:

[lib]
name = "my_library"
path = "src/lib.rs"

注意,这里是[lib]而不是[[lib]],这是因为在一个carge package中最多只能存在一个library crate,但binary crate可以有多个。

接下来,我们就看看一个由多个binary crate和一个library crate混合构成的rust项目。

4.3.1.3 多个Binary Crate和一个Library Crate

我们在organizing-rust-code/cargo/single-package/hybrid-crates下面执行如下命令创建这个多crates混合项目:

$cargo new my_project
     Created binary (application) `my_project` package

上述命令默认创建了一个binary crate的project,我们需要配置一下Cargo.toml,将其改造为多个crates并存的project:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "cmd1"
path = "src/main1.rs"

[[bin]]
name = "cmd2"
path = "src/main2.rs"

[lib]
name = "my_library"
path = "src/lib.rs"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

这里定义了三个crates。两个binary crates: cmd1、cmd2以及一个library crate:my_library。

如果我们执行cargo build,cargo会将三个crate都构建出来:

$cargo build
   Compiling my_project v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/single-package/hybrid-crates/my_project)
    Finished dev [unoptimized + debuginfo] target(s) in 0.80s

我们可以在target/debug下找到构建出的crates:cmd1、cmd2和libmy_library.rlib:

$ls target/debug
build/          cmd1.d          cmd2.d          examples/       libmy_library.d
cmd1*           cmd2*           deps/           incremental/        libmy_library.rlib

我们也可以通过cargo分别运行两个binary crate:

$cargo run --bin cmd1
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/cmd1`
cmd1

$cargo run --bin cmd2
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/cmd2`
cmd2

4.3.1.4 典型的cargo package

在The cargo book中,有一个典型的cargo package的示例:

.
├── Cargo.lock
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   └── bin/
│       ├── named-executable.rs
│       ├── another-executable.rs
│       └── multi-file-executable/
│           ├── main.rs
│           └── some_module.rs
├── benches/
│   ├── large-input.rs
│   └── multi-file-bench/
│       ├── main.rs
│       └── bench_module.rs
├── examples/
│   ├── simple.rs
│   └── multi-file-example/
│       ├── main.rs
│       └── ex_module.rs
└── tests/
    ├── some-integration-tests.rs
    └── multi-file-test/
        ├── main.rs
        └── test_module.rs

在这样一个典型的项目中:

  • Cargo.toml和Cargo.lock文件存储在包的根目录(包根目录)中。
  • 源代码位于src目录中。
  • 默认的库文件是src/lib.rs。
  • 默认的可执行文件是src/main.rs。
  • 其他可执行文件可以放在src/bin/目录中。
  • 基准测试位于benches目录中。
  • 示例位于examples目录中。
  • 集成测试位于tests目录中。

4.3.2 多package的rust项目

一些中大型的Rust项目都是多package的,比如rust的异步编程事实标准tokio库、刚刚升级为Apache基金会顶级项目的SQL查询引擎datafusion等。以tokio为例,这些项目的顶层Cargo.toml都是这样的:

// https://github.com/tokio-rs/tokio/blob/master/Cargo.toml
[workspace]
resolver = "2"
members = [
  "tokio",
  "tokio-macros",
  "tokio-test",
  "tokio-stream",
  "tokio-util",

  # Internal
  "benches",
  "examples",
  "stress-test",
  "tests-build",
  "tests-integration",
]

[workspace.metadata.spellcheck]
config = "spellcheck.toml"

上面这个Cargo.toml示例与我们在前面见到的Cargo.toml都不一样,它并不包含package配置,其主要的配置为workspace。我们看到workspace的members字段中配置了该项目下的其他package。正是通过这个配置,cargo可以在一个项目里管理和构建多个package。

工作空间(Workspace)是一组一个或多个包(Package)的集合,这些包称为工作空间成员(Workspace Members),它们一起被管理。接下来,我们就来创建一个多package的cargo项目。

4.3.2.1 cargo管理的多package项目

由于cargo并没有提供cargo new my-pakcage –workspace这样的命令行参数,项目的顶层Cargo.toml需要我们手动创建和编辑。

$cd organizing-rust-code/cargo/multi-packages
$mkdir my-workspace
$cd my-workspace
$cargo new package1 --bin
     Created binary (application) `package1` package
$cargo new package2 --lib
     Created library `package2` package
$cargo new package3 --lib
     Created library `package3` package

接下来,我们手工创建和编辑一下项目顶层的Cargo.toml如下:

// organizing-rust-code/cargo/multi-packages/my-workspace/Cargo.toml
[workspace]
resolver = "2"
members = [
    "package1",
    "package2",
    "package3",
]

保存后,我们可以在项目顶层目录下使用下面命令检查整个工作空间(workspace)中的所有包(package),确保它们的代码正确无误,不包含任何编译错误:

$cargo check --workspace
    Checking package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package1)
    Checking package2 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package2)
    Checking package3 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package3)
    Finished dev [unoptimized + debuginfo] target(s) in 0.18s

在顶层目录执行cargo build,cargo会build工作空间中的所有package:

$cargo build
   Compiling package3 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package3)
   Compiling package2 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package2)
   Compiling package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.64s

构建后,该项目的目录结构变成下面这个样子:

$tree -L 2 -F
.
├── Cargo.lock
├── Cargo.toml
├── package1/
│   ├── Cargo.toml
│   └── src/
├── package2/
│   ├── Cargo.toml
│   └── src/
├── package3/
│   ├── Cargo.toml
│   └── src/
└── target/
    ├── CACHEDIR.TAG
    └── debug/

我们看到该项目下的所有package共享一个共同的 Cargo.lock 文件,该文件位于工作空间的根目录下。并且,所有包共享一个共同的输出目录,默认情况下是工作空间根目录下的一个名为target的目录,该target目录下的布局如下:

$tree -F -L 2 ./target
./target
├── CACHEDIR.TAG
└── debug/
    ├── build/
    ├── deps/
    ├── examples/
    ├── incremental/
    ├── libpackage2.d
    ├── libpackage2.rlib
    ├── libpackage3.d
    ├── libpackage3.rlib
    ├── package1*
    └── package1.d

我们在这下面可以找到所有package的编译输出结果,比如package1、libpackage2.rlib以及libpackage3.rlib。

当然,你也可以指定一个package来构建或运行:

$cargo build -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
$cargo build -p package2
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/package1`
Hello, world!

4.3.2.2 带有外部依赖和内部依赖的多package项目

我们复制一份my-workspace,改名为my-workspace-with-deps,修改一下package1/src/main.rs,为其增加外部依赖rand crate:

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/src/main.rs
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
}

接下来,我们需要修改一下package1/Cargo.toml,手工加上对rand crate的依赖配置:

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/Cargo.toml
[package]
name = "package1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.8.5"

保存后,我们执行package1的构建:

$cargo build -p package1
  Downloaded getrandom v0.2.14 (registry `rsproxy`)
  Downloaded libc v0.2.154 (registry `rsproxy`)
  Downloaded 2 crates (780.6 KB) in 1m 07s
   Compiling libc v0.2.154
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.17
   Compiling getrandom v0.2.14
   Compiling rand_core v0.6.4
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 46s

我们看到:cargo会自动下载package1的直接外部依赖以及相关间接依赖。构建成功后,可以执行一下package1的编译结果:

$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/package1`
Random number: 3840180495

接下来,我们再为package1添加内部依赖,比如依赖package2的编译结果:

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/src/main.rs

extern crate package2;
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
    let result = package2::add(2, 2);
    println!("result: {}", result);
}

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/Cargo.toml
[package]
name = "package1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.8.5"
package2 = { path = "../package2" }

我们看到:package1的main.rs依赖package2这个crate中的add函数,我们在package1的Cargo.toml中为package1添加了新依赖package2,由于package2仅仅存放在本地,所以这里我们使用了path方式指定package2的位置。

我们执行一下添加内部依赖后的package1:

$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/package1`
Random number: 2485645524
result: 4

4.4 小结

本文循序渐进地讨论了在Rust项目中如何组织代码的问题,这对于Rust初学者来说尤为有用。

我们首先回顾了Go语言中的代码组织方式,介绍了Go项目代码组织的两个层级:module和package。然后,我们将Rust项目可以分为两种类型:使用rustc编译器的项目和使用Cargo的项目。

对于rustc-only的项目,开发者需要编写自己的构建脚本来管理项目的构建过程。

文章从最简单的单文件rustc-only项目开始介绍,展示了如何使用rustc编译器来编译和运行这种项目,并逐步介绍了带有外部依赖的rustc-only项目以及多文件项目的情况,引出了rust module概念。

rustc-only项目很少用于生产环境,这种方式主要用于学习和了解Rustc编译器的功能机制以及Rust语言的代码组织抽象。

在实际开发中,使用Cargo来创建和管理Rust包是常见的做法。在本章的后半段,我们介绍了使用cargo管理的rust项目的代码组织情况,包括单package项目和多package项目以及如何为项目引入外部和内部依赖。

总体而言,本文旨在帮助初学者理解和掌握Rust项目的代码组织结构,以提高学习效率和学习效果。通过介绍rustc-only项目和cargo管理的项目,读者可以逐步了解Rust代码组织的基本概念和实践方法。

本文涉及的源码可以在这里下载。

4.5 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats