面向-Windows-程序员的-C---软件互操作教程-全-
面向 Windows 程序员的 C++ 软件互操作教程(全)
一、准备
介绍
本章介绍了软件互操作性项目。我们先简要了解一下先决条件。接下来是项目概述。最后,我们描述了项目的主要组成部分以及它们是如何组织的。
先决条件
对于本书描述的项目,我们使用的主要工具是 Visual Studio 社区版 2019,既有 C++17,也有最新的。已安装的. NET framework 工作负载。我们还利用了 Boost,特别是 Boost 版本 1.76。附录 A 中描述了 Boost 的安装和设置。这些构成了 C++ 开发环境的核心。此外,对于具体的项目,我们使用 GoogleTest 和 Excel-DNA。如果需要,可以通过 Visual Studio 内部的包管理器轻松地安装或更新它们,我们将在相应的章节中描述如何做到这一点。
对于 R 包的开发,我们需要 RStudio Desktop(版本 1.4.1106)、R(版本 4.0.3)和 Rtools 4.0。所有这些都有可下载的 Windows 安装程序。最新(截至 2021 年 8 月)发行版的链接如下:
因为这些工具有不同的更新周期,所以分别安装它们是有用的,而不是试图使用 Anaconda 来一起管理它们。
对于 Python 开发,我们需要 Python 3.8。这可从 www.python.org/downloads/release/python-380/
得到。对 3.8 版没有具体要求。当这个项目开始的时候,它恰好是最新的版本。像以前一样,我们更喜欢独立地管理它,例如,Visual Studio Community Edition 2019。我们自己管理工具的安装给了我们更多的灵活性,代价是要多做一些工作来跟踪我们已经安装了哪些版本。
除了前面提到的工具,我们还使用代码块作为跨平台的 IDE。CodeBlocks 附带了 MinGW 工具集。我们使用代码块来管理和构建使用 gcc 的项目,我们使用 Visual Studio 代码作为 Python 开发的环境。这两者都不是严格要求的。对于有 gcc 的建筑,需要一个 mingw64 ( http://mingw-w64.org/doku.php
)的分布;除此之外,只需要一个 makefile。对于 Python 开发来说,除了使用 Visual Studio 代码,还有其他几种选择(例如 Jupyter、Idle 和 Spyder)。
如何使用这本书
这本书是关于构建软件组件的。它附带了一个 Visual Studio 解决方案(software inter operability . SLN),其中包含构建和测试包装组件的项目。到本书结束时,您将拥有将简单的 C++ 代码库连接到 C#(和)的工作包装组件。NET)、R 和 Python。在本书中,我们分阶段进行,从构建 C++ 库开始。然后,在接下来的章节中,我们将构建组件包装器和演示程序。所有的源代码都存在于各自的项目文件中,但是没有构建任何二进制组件。我们将给出如何配置和构建组件的说明,并解释代码的特性和一些设计选择。为了充分利用这本书,我建议按照这个顺序,因为后面的章节需要 C++ 库。如果构建不正确,那么依赖于它的组件将无法工作。我不建议尝试构建“开箱即用”的解决方案有需要安装的依赖项和需要配置的项目设置。这些在书中相应的地方都有涉及,我们将在涉及到它们的时候构建和测试这些组件。
在每一章的结尾,从第 2 到第八章,都有练习。这些练习的目的是提供一些实践,展示一些额外的功能,这些功能是组成软件互操作性项目的组件的一部分。这些练习旨在说明各章节所涵盖的问题类型。练习大致分等级,首先是较简单的任务,然后是中级和高级练习。前几章中的练习有相当详细的说明和提示,而后面的练习省略了详细的步骤。已经注意确保练习正确地工作。有一个单独的项目 zip 文件,其中包含所有已完成、测试和运行的任务。
软件互操作性项目
整个项目可以从出版商的网站上以 zip 文件的形式下载。您应该将内容克隆或解压缩到一个名为 SoftwareInteroperability 的目录中。从图 1-1 可以看出,目录结构相当扁平。
图 1-1
软件互操作性项目目录结构
大多数目录是单独的项目目录,包含项目文件、组件的源代码和测试。我们将在接下来的章节中更详细地描述这些。除了组件项目目录之外,还有几个其他目录值得强调。\普通目录包含两个子目录:\包含和\来源。这些包含了构成 C++ 统计库核心的公共头文件和源文件。 \packages 目录包含 GoogleTest 和 Excel-DNA 的 NuGet 包管理器信息。 \Data 目录包含许多小数据集,我们在不同的测试点使用它们。文件 xs.txt 和 ys.txt 在各种测试中使用。 us-mpg.txt 和 jp-mpg.txt (以及它们的等价物)。csv 文件)包含来自 seaborn 数据集 https://github.com/mwaskom/seaborn-data/blob/master/mpg.csv
的美国和日本汽车汽油消耗数据。还有一个 Excel 工作簿, StatsCLRTest.xlsx,包含 StatsCLR 组件的测试。我们将在第四章中介绍这一点。最后, \Exercises 目录包含了解决方案文件的压缩副本以及所有包含已完成练习的项目。 \x64 目录(调试和发布)包含构建工件。
这些项目
从图 1-1 可以看出,Visual Studio 解决方案software inter operability . SLN位于项目的根目录下。将 Visual Studio 项目与其他项目区分开来是很有用的。在 Visual Studio 解决方案中,总共有 12 个项目。这些项目如下所列,并附有项目类型的简短描述。
以下是 C++ 项目:
-
StatsCLR 是一个 C++/CLI 包装器。该组件将 C++ 静态库(StatsLib)连接到 C#(和。NET)。
-
StatsConsole 是一款 Windows 控制台应用。这个应用程序用一个最小的用户界面演示了统计库的基本功能。
-
StatsDll 是统计库的动态链接库版本。
-
StatsLib 是统计库的静态链接库版本。这两个组件和前面的组件共享相同的 C++ 源代码。
-
StatsLibTest 是一个基于 GoogleTest 的 Windows 控制台单元测试应用程序。
-
statspithonboost是一个助力。Python 包装器。该组件将 C++ 代码连接到 Python。
-
statspithonpybind是一个 PyBind 包装器。该组件将 C++ 代码连接到 Python。
-
statsphythonraw是一个 CPython 包装器。该组件将 C++ 代码连接到 Python。让三个组件做本质上相同的事情的原因是为了以更一般的方式涵盖将 C++ 连接到 Python 的不同方法。
-
StatsViewer 是一款使用 MFC(微软基础类)构建的 Windows 应用。这个应用程序用于演示在 GUI 应用程序中使用统计库中的可用功能。
以下是 C# 项目:
-
StatsCLR。UnitTests 是一个 C# 单元测试库,测试来自. NET 的基本统计功能。
-
StatsClient 是一个基本的 C# 控制台应用程序,它通过 StatsCLR 包装器组件使用最小的用户界面执行统计库的功能。
-
stat excel是一个 C# 库项目。该组件将 Excel 连接到 StatsCLR 包装组件,并允许 Excel 使用统计库中可用的功能。
在 Visual Studio 解决方案之外,还有三个进一步的项目。
-
StatsLibCB 是我们用来构建 R/RStudio ABI(应用二进制接口)兼容静态库的 CodeBlocks 项目。
-
StatsR 是 Rcpp 包装组件,通过 RStudio 使用 Rtools 构建。
-
StatsPython 是一个 Visual Studio 代码 Python 项目,包含许多使用统计库功能的脚本。
术语
在本书中,我们使用“组件”这个词作为一个通用术语,包含任何构建的软件单元( lib 、 dll 或 exe )。从 Windows C++ 项目的角度来看,包装组件都是动态链接库。然而,从客户端语言的角度来看,它们有不同的名称,反映了它们不同的内容。对于 C++/CLI,我们构建一个. NET 程序集;对于 R,我们构建一个包;对于 Python,我们构建(扩展)模块。
项目如何整合在一起
这些项目被组织起来形成一个小环境,允许为三种不同的客户端语言(C#、R 和 Python)或多或少地开发和构建 C++ 组件。图 1-2 说明了组件之间的关系。
图 1-2
软件生态系统
图顶部的中间方框包含 C++ 源代码。一些代码依赖于 Boost 库。相同的源代码用于构建三个组件:静态库( StatsLib.lib )、动态链接库(StatsLib.dll
)和用 gcc 构建的静态库( libStatsLib.a )。
在这些库的下面是包装组件和使用它们的“客户端”应用程序。应用程序 StatsLibTest、StatsConsole 和 StatsViewer 与 StatsLib 或 StatsDll 链接,主要用于执行和测试底层功能。包装器组件 StatsCLR 和 StatsPython 与 StatsLib 链接。StatsR 组件与 gcc 编译的 StatsLibCB 链接。
虚线表示 C++ 组件和各自的客户端语言之间的接口。对于 C++/CLI 程序集,我们有两个客户端,一个 C# 控制台应用程序(StatsClient)和一个连接到 Excel 的程序集(StatsExcel)。对于 R,R 包(StatsR)可以与 R、RGui 和 RStudio 交互使用,也可以与 R 脚本一起使用。类似地,Python 模块可以导入到任何 Python 脚本中,也可以交互使用。
摘要
在这简短的一章中,我们已经给出了软件互操作性项目组织的概述。在下一章,我们将更详细地研究 C++ 基础。
二、C++ 组件和 C++ 客户端
介绍
在这一章中,我们将介绍 C++ 代码库。这包括一小组统计函数和类。目的不是提供一个完全成熟的统计库,而是提供一些有限但有用的功能,我们希望向客户公开。该功能只有在说明我们以后如何向客户公开它时才感兴趣。
我们从查看代码开始,记下我们想要公开的函数和使用的类型。在后面的章节中,我们将会看到不同语言的客户端是如何处理调用 C++ 函数和类以及所需的类型转换的。接下来我们创建两个传统的 Windows C++ 组件:一个静态库( lib )和一个动态链接库( dll )。这是 Windows 平台上许多功能打包并向 Windows C++ 客户端公开的传统方式。我们会查看项目设置和配置。最后,我们展示两个客户端应用程序:StatsConsole 和 StatsViewer。这两个应用程序都使用了我们小型统计库中的功能。
源代码之旅
核心 C++ 代码都位于名称空间Stats
下。它涵盖四个主要领域:
-
描述性统计:这些是总结给定数据集各个方面的单个值,如其集中趋势和分布。描述性统计在探索性数据分析中是一种有用的策略。
-
线性回归:我们实现了一个单变量线性回归函数。线性回归模拟两个变量之间的线性关系。它可用于将预测模型与观察到的值数据集进行拟合。
-
数据管理器:这为数据集提供了一个简单的缓存机制。它使用户能够从文件中加载数据集,然后按需存储和检索它们。
-
统计测试:这里的代码形成了一个处理统计假设测试的类层次结构。我们有班级对学生进行 t 检验和 F 检验。粗略地说,t 检验测试两个平均值之间的差异,f 检验测试两个样本是否具有相同的方差。
我们将依次更详细地描述每一个。正如前面指出的,这里的目标不是用 C++ 生成一个完全成熟的统计库。相反,我们希望有一个足够简单易懂的代码库,但在我们希望向不同目标语言公开的功能方面,它比“Hello World”示例更现实。
描述统计学
描述性统计功能有两个部分:汇总统计函数和GetDescriptiveStatistics
包装函数。在Stats::DescriptiveStatistics
名称空间中是单独的函数。图 2-1 显示了我们计算的统计列表。
图 2-1
DescriptiveStatistics
名称空间的视图
从图 2-1 中我们可以看到,我们计算了一些基本的汇总统计量。这些包括集中趋势的度量(平均值和中值)和分布的度量(标准差、方差、最小值、最大值、范围、峰度和偏斜)。为了计算方差、标准偏差和标准误差,我们定义了一个VarianceType
枚举来区分population
和sample
计算。
函数的实现被刻意保持简单。清单 2-1 显示了计算范围的代码。
-
Listing 2-1Calculating the Range statistic
在可能的情况下,我们使用标准的库工具,如前面清单中的std::minmax_element
和其他计算中的std::accumulate
。同样,在前面的例子中,我们利用了 C++17 结构化绑定。在函数的顶部,我们检查输入的空值,并相应地抛出一个异常。我们没有试图优化功能。例如,我们分别计算Sum
和Mean
,也不会在计算标准差时重用Mean
函数。为了计算偏斜度(衡量数据集对称程度的指标)和峰度(衡量数据尾部“肥胖”程度的指标)以及过度峰度,我们将计算结果转发给boost::math:
:statistics
中的等效函数。
计算单个统计数据的函数没有直接公开。相反,我们选择将它们包装在一个函数调用中,如清单 2-2 所示。
-
Listing 2-2Two functions in the Stats namespace: GetDescriptiveStatistics and LinearRegression
从清单 2-2 可以看出,GetDescriptiveStatistics
函数有两个参数:第一个是输入数据,一个double
s 的向量,第二个参数是可选的键列表。关键字是我们想要计算的统计数据的名称。如果没有提供键或者键为空(默认设置),则返回所有摘要统计信息。统计数据以结果包的形式返回。清单 2-3 展示了实现。
-
Listing 2-3The implementation of GetDescriptiveStatistics
GetDescriptiveStatistics
功能(列表 2-3 )通过初始化功能图开始。我们将在本章的后面扩展这个代码部分。然后,代码为结果创建一个空的无序映射。如果我们希望在不影响任何客户端代码的情况下扩展统计数据,结果图非常方便,因为它提供了一定的灵活性。在函数体中,我们区分两种情况。如果键是空的,我们就调用映射statistical_functions
中的每个函数,并将键和相应的结果放在无序映射中。如果用户提供了密钥,我们就遍历它们并检查密钥名是否有效。如果是这样,我们调用函数,将结果放入结果图中。如果键不存在,那么我们在映射中报告这一情况,并继续处理键。
在清单 2-4 所示的局部静态std::map
中声明并初始化各个统计函数。
-
Listing 2-4The map of statistical functions
statistical_functions
映射定义了从命名统计到函数实现的映射,并提供了简单的函数分派机制。函数的类型为std::function<double(const std::vector<double>& data)
。也就是说,它们将一个向量double
s 作为输入参数,并返回一个double
。为了使用几个统计数据需要的VarianceType
参数,我们使用匿名 lambdas 作为一种方便的方法,使函数调用适应所需的类型。类似地,我们使用匿名 lambda 来调用Quantile
函数。我们硬编码第二个参数,对应于我们想要公开的分位数。在这种情况下,我们只公开“Q1
”(25%的分位数)和“Q3
”(75%的分位数)。然而,与Median
函数和Minimum
和Maximum
一起,我们可以提供一个有用的数据集的五个数摘要。
线性回归
在Stats
名称空间中,我们还声明了一个函数LinearRegression
。实现如清单 2-5 所示。
-
Listing 2-5Computing linear regression
该函数执行简单的单变量线性回归。在LinearRegression
函数的开始,我们检查输入向量的大小是否相同,如果不相同,就抛出一个异常。在这种情况下,我们利用Mean
函数来计算系数。我们以通常的方式计算各自平均值的偏差平方和。和以前一样,系数b0
和b1
以及在它们的计算中使用的标准值作为一个带有命名结果的包返回。这比返回一个std::pair<T>
或者一个std::tuple<T>
更加灵活。
数据管理器
DataManager
类是一个实用程序类,用于提供命名数据集的缓存。类图如图 2-2 所示。
图 2-2
DataManager
类图
对于不具备处理多个数据集的功能的客户端应用程序(例如,StatsConsole 应用程序),管理数据集非常有用。它允许加载和存储数据集,以后在执行统计操作时可以引用这些数据集。在这种情况下,数据集只是一个名称与一个向量double
的关联。数据集存储在一个unordered_map
中,以名称为关键字。从图 2-2 中我们可以看到,DataManager
类提供了添加、检索、计数、列出和删除所有数据集的工具。代码可以在 DataManager.cpp 中找到。在客户端应用程序中,我们通常不使用这个类(例如,Excel 和 R/RStudio 都提供了更好的管理数据集的方法),但是如果需要,我们可以向客户端公开它。
统计测试
StatisticalTest
类的层次结构如图 2-3 所示。
图 2-3
StatisticalTest
阶级阶层
从图 2-3 可以看出,类层次结构由一个基类StatisticalTest
和两个派生类组成。TTest
类用于执行学生的 t 检验,FTest
用于执行 F 检验。如果需要,可以将更多的类添加到层次结构中,例如,z 测试类(练习之一)或卡方测试类。
StatisticalTest
类为执行统计假设测试提供了一个抽象基类。基类声明如清单 2-6 所示。默认的移动和复制构造函数和操作符已经被省略。
-
Listing 2-6The abstract base class for statistical tests
基类有一个纯粹的虚函数,Perform,
,用于在派生类中执行所需的计算。通常,在派生类中,我们可以使用构造函数来配置计算的具体细节。另一种方法是定义测试参数的进一步的类层次结构,派生类在执行测试时可以对其进行操作。然而,为了简单起见,我们只使用派生类构造函数。这样做的好处是,对于如何设置派生类没有任何限制。统计测试的结果存储在基类的std::unordered_map
中,和以前一样,我们存储命名的结果。可以使用Results
函数按需检索结果集。在这种情况下,我们不会让用户选择检索哪个特定的结果。
TTest
和FTest
类在功能上非常相似,所以我们将只详细研究TTest
类。类声明如清单 2-7 所示。
-
Listing 2-7The derived class used to perform a student’s t-test
TTest
类被声明为final
,所以我们不期望用户从它派生。和以前一样,为了简洁起见,清单中省略了默认函数和标准操作符(复制和移动赋值等等)。派生类的构造函数用于区分我们感兴趣的三种类型的 t 测试。汇总数据 t-test 接受汇总统计数据作为输入。它使用已知的总体均值和样本均值、标准差和样本大小的汇总度量。另一方面,单样本 t 检验接受已知的总体均值和数据集。数据集用于计算汇总数据 t 检验中使用的相同汇总度量(平均值、标准偏差和样本大小)。双样本 t 检验比较两个样本,特别是样本均值。双样本 t 检验有许多变体(例如,我们不支持的配对 t 检验)。在这个版本中,我们假设两个样本数据集具有相等的方差。构造函数用于初始化适当的成员变量,也用于设置测试类型。在调用Perform
函数之前,不会进行实际的计算。Perform
功能检查测试类型,并根据需要从存储的输入数据中计算所需的值。清单 2-8 显示的是汇总数据和单样本案例。
-
Listing 2-8Calculations from a one-sample t-test
在清单 2-8 中,我们检查测试类型并计算缺失值:样本大小、样本均值和标准偏差。如果这是一个汇总数据 t-test,这些值将会在构造函数参数中提供。Perform
使用来自DescriptiveStatistics
名称空间的Mean
和StandardDeviation
函数。虽然 t 统计易于计算,但 p 值需要学生 t 分布的 cdf(累积分布函数)。为此,我们利用boost::math:
:statistics
。具体来说,我们使用了one_sample_t_test
和two_sample_t_test
包装函数。代码可以在\ boost \ math \ distributions \ students _ t . HPP中找到。这些函数计算所需的统计数据、t 值和 p 值,并以元组的形式返回结果。其余的值被计算并作为命名结果包返回。
FTest
类的结构类似。有两个构造函数:一个用于汇总数据,另一个在提供两个样本数据集时使用。Perform
函数利用boost::math::fisher_f
分布来计算 p 值。结果作为一个包在一个std::unordered_map
中返回。
函数、类和类型转换
现在我们对源代码有了一个很好的概述,我们可以看到我们可能需要向不同语言的客户端公开什么。我们有以下函数:GetDescriptiveStatistics
和LinearRegression
;以及以下几类:DataManager
和TTest
。我们还需要执行从一种语言到另一种语言的类型转换。一般来说,我们可以区分三个领域:使用内置类型的转换,使用标准库类型的转换,以及涉及用户定义类型的转换(C++ class
es 和struct
s)。就内置类型而言,我们有以下几种:bool
、double
、std::size_t
。就标准库类型而言,我们有std::string
、std::vector<double>
、std::unordered_map<std::string, double>
、DataSetInfo
,我们将typedef
称为std::pair<std::string, std::size_t>
和std::exception
。最后,我们有用户定义的类型:DataManager
和TTest
。在接下来的章节中,我们将会看到如何使用不同的框架来处理这些类型转换。
C++ 组件
统计库
现在我们已经看到了代码,是时候把它打包成一个库了。StatsLib 是我们统计函数的静态库包。一般来说,静态库是重用代码的一种有用的方式,并且很容易合并到其他 Windows 可执行文件中。
先决条件
该项目的唯一先决条件是安装了 Boost 库。如果您还没有安装 boost,请参见附录 A 了解如何安装。前面说过,我们用的是 Boost 1.76。该项目引用了 StatisticalTests.cpp 中的两个 Boost 头文件:
#include <boost/math/statistics/t_test.hpp>
#include <boost/math/distributions/fisher_f.hpp>
此外,在descriptivestatistics . CPP中,我们需要以下标题来衡量偏斜度和峰度:
#include <boost/math/statistics/univariate_statistics.hpp>
这只需要 Boost 代码。但是,稍后,我们会使用 Boost。Python,需要构建 Boost 库。如果可能的话,在这个早期阶段解决这个问题是值得的。
项目设置和配置
初始 StatsLib 项目属性如图 2-4 所示。
图 2-4
StatsLib 属性页
在属性页的顶部,我们将配置设置为所有配置,并将平台设置为 x64。本书中的所有项目都是针对 x64 平台的。这很方便,因为我们也依赖于针对 x64 构建的库。具体来说,我们依赖 Boost 和 Python 拥有 x64 版本的库。此外,我们安装了 64 位 Excel,在使用 Excel-DNA 构建 StatsExcel 组件时,我们使用 x64 目标。根据客户端的不同,您可以将其更改为 x86/Win32 目标,并调整不同的项目配置。在整本书中,我们主要讨论调试配置。同样,这只是我们讨论调试时的一个方便。我们还可以在 x64 版本中构建。在配置属性中,在 General 下,我们将 C++ 语言标准设置为 ISO C++17 标准(/std:c++17
)。
在 C/C++ 节点中,在 General,Additional Include Directories 下,我们需要添加包含 Boost 库的目录。如果您之前已经设置了BOOST_ROOT
环境变量,它将在宏下显示为$(BOOST_ROOT)
。展开宏> >按钮,如果变量不存在,插入变量。否则,检查路径是否指向 Boost 安装目录(在本例中:D:\ Development \ Libraries \ Boost _ 1 _ 76 _ 0)。如图 2-5 所示。
图 2-5
附加目录包括$(
和BOOST_ROOT)
此外,我们需要用这个项目的头文件引用 include 目录。在一个“普通”的项目中,这是不必要的,因为项目源和包含很可能在项目目录下。然而,由于我们为三个不同的项目共享相同的源代码(和头文件),我们需要告诉编译器在哪里可以找到它们。它们在项目目录下:$(solution dir)Common \ include。这就是所有需要的配置。
构建和测试项目
有了这些设置,项目应该在没有警告或错误的情况下构建。你可能想试试这个。任何错误都可能来自未正确配置的路径,特别是 Boost 头文件以及项目源文件和包含文件的路径。如果项目没有生成,请检查这些设置。一旦项目构建完成,测试它是一个好主意。
StatsLibTest
StatsLibTest 是一个小型控制台应用程序,它使用 GoogleTest 提供一个易于使用的单元测试框架来测试 StatsLib。GoogleTest 是一个只有标题的单元测试框架( https://github.com/google/googletest
)。这里描述的是安装 Google test(https://docs.microsoft.com/en-us/visualstudio/test/how-to-use-google-test-for-cpp?view=vs-2019
)。由于它是使用 NuGet 包管理器安装到项目中的,我们可以很容易地检查我们是否需要使用 Visual Studio 菜单工具➤ NuGet 包管理器➤管理 NuGet 包解决方案来升级包。项目配置方面没有太大的问题。我们添加了对 StatsLib 项目的引用。
在这个项目中,我们使用了预编译头文件。我们已经为我们使用的 STL 库添加了 includes,并且我们已经添加了一个简单的函数来从文件中读取数据并将其加载到一个std::vector<double>
中。在名称空间StatsLibTest::Utils
中,我们还有一个简单的TestSettings
类,它存储了 \Data 目录的位置。从 Visual Studio 运行时,这作为第一个命令行参数传入。在配置属性下,调试➤命令参数我们设置 $(SolutionDir)Data 指向数据目录。
预编译头文件还包括主 GoogleTest 头文件:
#include "gtest/gtest.h"
如果你需要设置额外的测试选项,这些可以在主菜单的工具➤选项➤测试适配器下找到。Google 测试文档中有更多关于这些设置的信息。
其余文件包含 StatsLib 函数的单元测试。它们被组织起来以反映我们想要在 StatsLib 中测试的实体。
-
Test _ descriptive _ stats . CPP:描述统计函数的测试用例
-
test_data_manager.cpp :数据管理器类的测试用例
-
Test _ linear _ regression . CPP:简单线性回归函数的测试用例
-
Test _ statistical _ tests . CPP:t-Test 和 f 检验假设测试类的测试用例
测试本身遵循排列-动作-断言模式。清单 2-9 显示了测试单样本 t-test 返回值的代码。
-
Listing 2-9Testing the one-sample t-test
清单 2-9 中的测试首先读取数据,然后用相应的参数(一个已知的总体平均值和数据集)构建一个TTest
实例。它先调用Perform
,然后调用Results
。使用宏EXPECT_EQ
、【带公差的 和EXPECT_DOUBLE_EQ
将结果与期望值进行核对。我们还测试了单样本 t-test 由于输入数据为空而引发异常的情况。这显示在清单 2-10 中。
-
Listing 2-10Checking exception handling
在清单 2-10 中,TTest
实例是用空数据构建的,并按照预期抛出std::invalid_argument
,因此测试通过。
构建、运行和检查结果
StatsLibTest 应该在没有警告或错误的情况下构建和运行。控制台上会显示结果摘要。不应该有测试错误。要查看输出到文件的结果,请打开项目属性,然后依次选择调试、命令参数。添加以下一行:
--gtest_output=xml:<your path>\SoftwareInteroperability\StatsLibTest\Output\StatsLibTest.xml
总的来说,如果我们想要扩展统计库提供的功能,StatsLibTest 项目提供了我们需要的测试支持。
StatsDll
StatsDll 是一个动态链接库,包含了我们的统计函数。它包含与 StatsLib 相同的源代码,但是在这种情况下,我们将功能打包为一个 dll 。
项目设置和配置
在 Visual Studio 解决方案中,在项目设置下,我们可以看到在配置属性中,在常规下,配置类型设置为动态库( dll )。额外的包含目录的设置方式与之前相同。它们引用 Boost 库和带有我们的头文件的目录。
$(BOOST_ROOT)
$(SolutionDir)Common\include
这个库和静态库的唯一区别在于配置属性➤ C/C++ 下的预处理器定义。用于 Windows dll s 的 Visual Studio 项目模板将<PROJECTNAME>_EXPORTS
的定义添加到已定义的预处理器宏中。在本例中,Visual Studio 在构建 StatsDll DLL 项目时定义了STATSDLL_EXPORTS
。
在代码中,我们通常定义一个 API 符号,如清单 2-11 所示。
-
Listing 2-11Standard preprocessor definitions
如果我们正在构建一个静态库(DYNAMIC
未定义),那么我们不关心__declspec(...)
指令,并且STATSDLL_API
符号计算为空。另一方面,如果我们正在构建一个动态库(在项目预处理器定义中定义了DYNAMIC
,那么我们使用符号STATSDLL_EXPORTS
来标识导出的函数。当STATSDLL_EXPORTS
宏被定义时,STATSDLL_API
宏在函数声明上设置__declspec(dllexport)
修饰符。这个修饰符告诉编译器和链接器从 DLL 中导出一个函数或变量供其他应用程序使用。例如,在清单 2-2 中,我们在构建 StatsDll 组件时标识了两个用于导出的函数GetDescriptiveStatistics
和LinearRegression
。当STATSDLL_EXPORTS
未定义时,例如,当头文件由客户端应用程序包含时,STATSDLL_API
将__declspec(dllimport)
修饰符应用于声明,这表明我们想要在应用程序中导入函数或变量。
构建和测试项目
我们现在可以构建 StatsDll 项目了。它应该不会出现警告或错误。如果有错误,最可能的原因是路径设置,所以您应该检查这些错误。如果你愿意的话,你可以使用像Depends.exe或者dumpbin.exe这样的工具来查看已经导出的函数和类。因为底层代码与 StatsLib 相同,所以我们不需要单独测试它。
到目前为止,在本章中,我们已经基于相同的源代码构建了两个传统的 C++ 组件:一个静态库和一个动态链接库。这些组件包含了我们将在接下来的章节中提供给各种非 C++ 语言客户端的功能。构建这两种 C++ 组件的原因是它们是在 Windows 上打包功能的典型方式。
从我们在本书中构建的组件的角度来看,链接到静态库或动态库之间几乎没有区别。然而,在一个更现实的场景中,例如当开发一个连接遗留 C++ 代码库和 Python 的中间层组件时,您不能选择现有功能如何呈现给您。它可能在一个静态库中,也可能在一个动态链接库中(它也可能在一个只有头文件的库中,或者它可能嵌入在一个可执行文件中,但是我们在本书中不讨论这两种情况)。当连接到 C++ 静态库或动态链接库时,我们在第 3 到第八章中构建的组件将同样工作良好。然而,为了便于说明,我们经常使用静态库 StatsLib。
C++ 客户端
介绍
在本节中,我们简要介绍两个客户端应用程序:StatsConsole 和 StatsViewer。这两个应用程序都使用了我们小型统计库中的功能。除了是如何使用函数的有用测试之外,它还在 C++ 客户端如何连接到 C++ 组件方面具有指导意义。
统计控制台
StatsConsole 是一个典型的 Windows 控制台应用程序。该应用程序包括一个菜单管理器。这将在控制台输出上显示菜单项,并根据用户输入选择管理功能分派。图 2-6 显示了从汇总数据执行 t-test 并显示结果的典型示例会话。
图 2-6
StatsConsole 中的典型会话
其他会话可能包括从文件中加载一个或多个数据集,通过向数据管理器添加名称来缓存这些数据集,然后检索描述性统计数据并执行线性回归。
StatsConsole 链接到静态库 StatsLib。我们所要做的就是引用头文件并添加相应的 lib 文件(或者我们可以只添加一个项目引用),函数和类就立即可供我们使用了。
统计查看器
与 StatsConsole 相比,StatsViewer 应用程序更加复杂。这是一个典型的菜单驱动的 Windows MFC 应用程序。从用户界面的角度来看,它使用单一文档界面(SDI)。主视图由两个列表控件组成。左侧的列表控件显示已加载的任何数据集的内容。自动添加索引列以供参考。右边的列表控件显示统计菜单操作的结果。图 2-7 显示了加载数据集和检索描述性统计数据后的典型会话。如果需要,可以将结果复制到剪贴板。
图 2-7
StatsViewer 显示 us-mpg 数据集的描述性统计数据
在这个应用程序中,我们使用 StatsDll 连接到统计函数。除了引用头文件和包含 dll (或者引用项目),我们不需要做任何进一步的配置。在这两个应用程序中,StatsLib 或 StatsDll 中的所有导出函数都是可用的,并且可以无缝使用。
在这两个例子中,我们看到了如何利用 C++ 组件中的功能。我们只需将组件添加到 C++ 可执行主机中。总之,这些应用程序用来说明我们如何以一种现实的方式利用真正的 C++ 组件中打包的功能。
然而,在各自的托管环境中存在一些限制。通过 Windows 控制台应用程序公开功能有些限制。例如,它限制了数据输入的选择。即使是功能更加全面的 MFC 应用程序,我们也可以看到各种问题。表格显示还有很多不尽人意的地方。列表控件不一定是显示数据网格的最佳选择。毫无疑问,有许多替代方法,包括 ActiveX 网格组件,可以做得更好。然而,如果我们想要绘制数据,也许是一个美国-mpg 对日本-mpg 数据集的方框图,该怎么办呢?虽然统计库提供了对这些数据进行 t-test 的功能,但是我们必须在其他地方进行绘图。同样,还有其他组件可以做到这一点(例如 gnuplot)。然而,将它连接到我们的统计库的接口是 gnuplot 特有的,不容易通用化。
更重要的是,不管 C++ 语言在执行计算、线程或并发处理方面有多好,不管函数库的范围有多广,我们仍然希望能够在其他种类的客户端软件中使用这些代码。问题不仅在于我们在应用程序中所能实现的限制,还在于该功能如何与其他组件进行互操作的限制。
对于静态或动态链接库中的功能,问题出现了:如何使它对其他语言或其他类型的客户端可用?当然,我们可以用不同的方式包装功能。我们可以将它构建成一个只有头文件的库。这无疑比动态链接库更容易分发,使用起来也更方便。但是,这只是在 C++ 世界中方便。我们仍然会有同样的问题:如果我们想将它与机器学习库或 R 脚本一起使用,或者从使用 Pandas 和 NumPy 的 Python 脚本中使用呢?
我们可能考虑的一个替代方案是开发一个 COM 包装器。例如,我们可以使用 VBScript 的功能。或者我们可以用。NET,利用。NET Interop 从嵌入的类型信息中生成所需的“包装”代码。事实上,这类似于我们在第三章中使用 C++/CLI 所采用的方法。然而,COM 属于较老的技术,需要熟悉组件注册、编组和线程模型等主题。开发 COM 包装更加复杂。这部分是因为框架(例如 MFC 和 ATL)尽管已经存在了一段时间,但还没有很好地发展或一致,还因为它们提供了相当低级的设施,让您做更多的工作。
可以说,大多数现代组件开发都是在。NET 处理和解决源自 COM 的问题(例如,注册、类型信息、组件版本、封送、线程模型等)。为了使 StatsLib 中的功能对其他语言和客户端可用,我们需要开发一些组件来弥补这一缺陷,并在它们各自的环境中形成松散耦合组件系统的一部分。NET、R 和 Python)。
摘要
在本章中,我们已经为开发连接到其他语言的组件打下了基础。我们已经查看了源代码,并构建了两个打包了功能的 Windows 库。我们已经看到了如何在两个不同的客户机中使用这些 C++ 组件:一个简单的控制台应用程序和一个功能更全面的 Windows GUI 应用程序。这将我们带到了庞大但有限的 C++ 世界的边缘。有更多的潜在客户端应用程序可以利用我们的库中的功能,使用更广泛的技术和语言。如果我们想更进一步——WPF(Windows Presentation Foundation)应用程序、web 应用程序、微服务、基于 R 构建的闪亮 tableau 或 Python 中的机器学习应用程序,仅举几个例子,我们将需要构建额外的组件来为这些位于任何所需模块中的客户端执行适当的翻译。本书的目的是向您展示如何在 C#、R 和 Python 中做到这一点。
如果能够用 C++ 编写一个组件,并简单地将其“放到”C# 应用程序中,或者直接从 R 或 Python 中调用该功能,那将是非常理想的。但是这样做是不可能的(据我所知),所以我们必须构建允许我们这样做的组件。本书的其余部分是关于构建和使用这些组件的。
额外资源
以下链接包含本章所涵盖主题的一些有用背景信息:
-
涵盖我们在此使用的所有统计数据的一个有用资源是《工程统计手册:
www.itl.nist.gov/div898/handbook/index.htm
。这提供了统计的详细定义、公式和解释。 -
关于
boost::math:
:statistics
提供的功能的更多信息,我推荐位于www.boost.org/doc/libs/1_77_0/libs/math/doc/html/math_toolkit/univariate_statistics.html
和www.boost.org/doc/libs/1_77_0/libs/math/doc/html/statistics.html
的数学工具包和单变量统计库文档。 -
这里记录了 GoogleTest 单元测试框架:
https://github.com/google/googletest
。有许多关于 GoogleTest 的一般测试实践和特定功能的有用参考。 -
中使用 COM 互操作的方法。这里描述的网名:
https://docs.microsoft.com/en-us/dotnet/standard/native-interop/cominterop
。
练习
本章中的练习扩展了 C++ 代码库。我们将处理三组主要的变化。首先,我们看看添加一些更多的结果到描述性统计和线性回归函数。其次,我们向StatisticalTest
类的层次结构中添加一个 z-test 类。最后,我们合并了一个来自TimeSeries
类的MovingAverage
函数。此处添加的功能将在后面的练习中使用,我们将使其可用于不同的客户端语言。
1)除了专用的Median
功能,还有一个更通用的Quantile
功能。因此,Median
功能是不必要的。在函数映射中,使用带有quantile=0.5
的Quantile
函数,用匿名 lambda 替换函数Median
。保留原来的"Median"
标签。
或者,添加一个新的函数映射,标记为:"Q2"
。这将被合并到结果包中,因此可以立即检查。
- 在重新生成 StatsLib 项目、StatsDll 项目和 StatsLibTest 项目之后,检查所有测试是否通过。您应该不需要为此添加单独的测试。所有的测试结果应该保持不变。重建统计控制台和统计查看器。检查是否显示了预期的结果。
2)增加计算相关系数 r 和r2 系数作为LinearRegression
结果的一部分的支持。相关系数衡量观察值和预测值之间的相关性。r2 统计表明回归系数解释了多少变化。
使用我们在代码中使用的相同的平方和命名约定,计算如下
-
增加对r2 的计算。另外,将新的系数添加到结果包中。
-
在 StatsLibTest 项目中,在测试文件test _ linear _ regression . CPP中,在测试夹具
TestLinearRegression
中,添加代码来测试结果。重新生成 StatsLibTest 项目。检查所有测试是否通过。 -
运行 StatsConsole 应用程序。加载 xs 和 ys 数据集(测试中使用的相同数据集)。检查回归系数和相关系数是否显示。使用 StatsViewer 应用程序运行类似的测试。
const double r = ss_xy / std::sqrt(ss_xx * ss_yy);
3)在 StatsLib 项目中,有一个名为TimeSeries
的类已经包含在源代码中。它只有一个功能:MovingAverage
。该功能已经有一个测试: test_time_series.cpp 中的TestMovingAverage
。
- 在 StatsConsole 项目中添加对“移动平均”菜单项的支持。
为了直观地检查结果,您可以运行一个 StatsConsole 会话,加载 moving_average.txt 并将窗口设置为3
。这是在TestMovingAverage
测试中使用的相同数据。
4)将 z 测试类添加到统计测试类层次结构中。z 检验是一种在方差已知且样本量较大(通常≥30)时确定两个样本均值大致相同还是不同的方法。这里更全面的描述了 z-test:https://en.wikipedia.org/wiki/Z-test
。
对于这个统计测试,我们只使用 Boost 功能boost::math::cdf
。ZTest
类是从StatisticalTest
基类派生而来的。z 检验在功能上(不是统计上)几乎与 t 检验相同。唯一的变化是基础分布。对于这个练习,我们可以克隆 t-test 代码来实现 z-test 类,它以几乎相同的方式工作。这也简化了我们将对即将到来的包装组件进行的增强。需要强调的是,这是一种便利。有几个更好的设计选择,但这些将涉及更多的变化在这里和以后。因此,为了简单起见,我们将创建一个新的类似于TTest
类的ZTest
类,并使用 Boost 实现。
按照以下步骤实施 z 测试功能:
-
派生一个新的
ZTest
类,并将类定义添加到 StatisticalTests.h 。 -
在 StatisticalTests.cpp 中,添加包含文件:
#include <boost/math/statistics/z_test.hpp>
-
将类实现添加到 StatisticalTests.cpp 中。以与 t-test 相同的方式实现构造函数:也就是说,我们可以对数据进行 z-test,一个单样本 z-test 和一个双样本 z-test。
-
实现功能
Perform
。我们需要在这里做一些改变:-
在函数顶部添加正态分布的声明:
boost::math::normal dist;
-
如下计算单样本和双样本情况下的测试统计量(使用与 t-test 相同的命名约定):
// One-sample case const double t = (m_x_bar - m_mu0) / (m_sx / std::sqrt(m_n)); // ... // Two-sample case const double t = (mean1 - mean2) / std::sqrt((((sd1 * sd1) / n1) + ((sd2 * sd2) / n2)));
-
向获得 p 值的两种测试类型添加代码:
double p = 2.0 * boost::math::cdf(boost::math::complement(dist, test_statistic));
-
-
Results
函数的实现已经在基类中处理了。在这种情况下,我们只是将相同的值添加到结果包中。我们将检验统计的名称从“t
”更改为“z
”,以反映我们已经执行了 z 检验的事实。我们也可以删除“df
”(自由度)结果,因为它不用于 z 测试。 -
重新生成 StatsLib 项目和 StatsDll 项目。获取三种情况下的检验数据:数据 z 检验、单样本 z 检验和双样本 z 检验。在 StatsLibTest 中,添加适当的测试。
三、构建 C++/CLI 包装器
介绍
在本章中,我们将构建我们的第一个包装组件。我们使用 C++/CLI 允许。NET 客户端调用 C++ 代码。我们使用 StatsLib,我们在第二章中构建的统计函数库,并通过 C++/CLI 公开该功能。
本章的目的是创建一个完全工作和可用的。NET 组件,尽管功能有限。这样做的原因是为了在现实环境中涵盖尽可能多的技术细节。另一方面,本章不是 C++/CLI 的参考手册。C++/CLI 语言规范是一个大型文档,它规定了实现 C++/CLI 绑定的要求。它深入地涵盖了该语言的所有特性。此外,我们并不打算在我们的报道面面俱到。我们将自己限制在我们正在开发的组件的细节所要求的范围内。我们让有限的代码来决定我们涵盖的主题。本章末尾的“附加资源”部分提供了其他地方更详细介绍的主题的链接。
我们想要包装的底层统计库(StatsLib)具有足够的功能,使它变得有趣,并作为进一步开发的出发点。这一章的目的是用一种足够现实的方式来展示一个特定的架构选择,以便于使用。通过编写一个小组件来连接两种语言,我们能够将统计功能从客户端使用中分离出来。此外,本章还演示了 C++/CLI 包装中涉及的内部设计,将公开函数和类的代码与负责在托管和非托管环境之间转换类型的代码层分开。
先决条件
C++/CLI 支持
该项目的主要先决条件是安装对 C++/CLI 的支持。C++/CLI 支持是 Visual Studio Community 2019 中的额外工作负载,可以使用 Visual Studio 安装程序进行安装。如果您已经安装了它,您可以跳过这一部分。如果没有,启动安装程序,从右边的“使用 C++ 进行桌面开发”部分,选择“C++/CLI 支持”。如图 3-1 所示。
图 3-1
安装 C++/CLI 支持
之后,选择您喜欢的下载和安装选项,并安装工作负载。
StatsCLR
项目设置
StatsCLR 项目是用 CLR 类库(.NET Framework)面向 C++ 的项目模板。我们同样可以使用 CLR 空项目(。NET 框架)。但是,前者生成我们使用的预编译头文件和 AssemblyInfo.cpp 属性。
表 3-1 总结了项目设置。
表 3-1
StatsCLR 项目设置
|设置
| | |
| --- | --- | --- |
| 标签 | 财产 | 价值 |
| 一般 | C++ 语言标准 | ISO C++17 标准版(/std:c++17) |
| 高级> C++/CLI 属性 | 公共语言运行时支持 | 公共语言运行时支持(/clr) |
| C/C++ >常规 | 其他包含目录 | $(解决方案目录)通用\包含 |
从表 3-1 中,我们将 C++ 语言标准设置为 C++17。这是为了与 StatsLib 项目保持一致。接下来,我们设置了附加的包含目录。我们需要一个对 C++ 代码的引用,所以我们将其设置为$(solution dir)Common \ include。我们还向项目引用节点添加了对 StatsLib 项目的引用。这个项目最重要的设置是公共语言运行时支持。如图 3-2 所示。
图 3-2
设置 CLR 支持
/clr 开关是 C++/CLI 项目的默认选项。开关允许使用托管运行时。C++/CLI 项目有时被称为混合程序集,因为它们同时包含非托管机器指令和 MSIL (Microsoft 中间语言)指令。 1 从实用的角度来看,这种安排允许我们混合使用。NET 和原生 C++ 代码。使用托管运行时意味着我们可以添加对其他。NET 框架组件添加到项目中,使用using
指令,我们可以访问所有的。NET 功能。例如,在这个项目中,我们利用。NET 泛型,方法是包含以下代码行:
using namespace System::Collections::Generic;
除了像我们在这里所做的那样从头开始开发一个独立的符合 clr 的包装组件,还有其他的架构选择。我们可以直接使用 StatsLib 项目,并用 /clr 开关重新编译它。然而,这种方法在某种程度上依赖于我们在库中引入的依赖项(例如,在我们的例子中,我们依赖于 Boost 库)。这可能会导致编译时出现问题。另一种方法是在同一个文件中使用相应的#pragma
指令将函数编译为托管或非托管。这两种方法我们都不使用。相反,我们更喜欢保持简单。因此,我们将底层的原生 C++ 库与包装组件分开。
代码组织
StatsCLR 项目由以下文件组成,并简要描述了它们包含的内容:
-
Conversion.h/。cpp 这包含了类型转换函数。
-
DataManager.h/。cpp 这包含了本机 C++
DataManager
类的托管包装。 -
Statistics.h/。cpp 它包含了本机 C++ 统计测试类的托管包装。
-
pch.h/。cpp 这是预编译的头文件。
-
项目的资源文件。
-
AssemblyInfo.cpp 它包含关于这个程序集的基本元数据:名称、描述、版本等。
代码被组织到 StatsCLR 项目名称空间下的两个独立的名称空间中:Functions
和Conversion
。在开发包装层时,将这两项任务分开是很有帮助的。Functions
名称空间组织调用函数和类的代码。在这种情况下,我们将查看Statistics
类,然后是DataManager,
,最后是StatisticalTest
类。名称空间包含了类型转换代码。这为我们选择如何处理转换提供了一定程度的灵活性。
统计课
我们从查看Statistics
类开始。代码复制在清单 3-1 中。
-
Listing 3-1The Statistics class declaration
这段代码有几个值得强调的特性。我们将Statistics
声明为ref class
。ref class
是一个 C++/CLI 类。这将创建一个引用类型,其生存期由 CLR 自动管理。如果您想创建一个可在 C# 中使用的类,您通常会创建一个ref class
。例如,可以使用 C# 的new
操作符来调用它。类似地,一个ref struct
做完全相同的事情,但是使用 C++ 的标准struct
默认可访问性规则。在这种情况下,因为我们只有静态函数,所以没有构造函数或析构函数。接下来,abstract
关键字将这个类声明为一个可以用作基类型的类型,但是不能被实例化。接下来,sealed
关键字(用于ref
类)表示虚拟成员不能被覆盖,或者某个类型不能用作基类型。因此,我们不能从这个类派生。它类似于 C++ 类上的final
关键字。可以从 C# 代码中调用这些函数,如下所示:
List<double> xs = new List<double> {0,1,2,3,4,5,6,7,8,9};
Dictionary<string, double> results = Statistics.DescriptiveStatistics(xs);
最后一个值得注意的特性是引用句柄,在 C++/CLI 中用^表示。这可以被认为是一种不透明的指针类型。更重要的是它引用的内存是托管的。这里我们需要区分非托管内存(从 C++ 的operator new
返回给我们的内存)和托管内存,前者来自 CRT (C-Runtime Library)堆,后者由。NET 运行时,并由垃圾收集器(GC)恢复。让 GC 代表我们管理内存在某种程度上简化了我们的代码。不需要显式删除托管内存。另一方面,我们需要意识到被引用的托管内存没有固定的地址,可能会被移动。因此,在 C++/CLI 中处理内存时,需要给予特殊的考虑。
参数和返回值
在前面提到的函数中,我们将参数作为通用列表List<double>^
传递。这是我们做出的选择。鉴于底层类型是一个std::vector<double>
,这似乎是合理的。例如,我们可以选择一个数组类型或其他更适合我们目的的类型。这里重要的一点是,这是一种选择。鉴于此,我们需要将List<double>^
转换为std::vector<double>
。类似地,LinearRegression
函数的返回类型是一个由string
键和double
值组成的字典。声明为Dictionary<String^, double>^
。和以前一样,我们一直试图保持接近底层类型std::unordered_map<std::string, double>
。然而,还有其他选择。我们可以使用List<Tuple<String^, double>>
,在某些方面这更好地代表了std::unordered_map
,因为它不像Dictionary
集合那样强加任何顺序。
描述统计学
函数DescriptiveStatistics
的实现非常简单。它与清单 3-2 中的重载版本一起显示。
-
Listing 3-2The implementation of the DescriptiveStatistics function and the overloaded version
在清单 3-2 中,该函数首先将传入的托管List<double>
转换成一个std::vector<double>
。类似地,按键从List<String^>
转换为std::vector<std::string>
。接下来,我们将参数传递给我们的本机函数,它将结果打包为一个std::unordered_map
,以string
为键,以double
为值。然后,结果包被转换回托管字典,并传递回调用者。我们将在下面更详细地处理这些转换。局部变量用前导下划线声明,以区别于参数和返回值。这只是为了避免产生额外的变量名。
原生 C++ 函数GetDescriptiveStatistics
有一个默认的第二个参数,键的向量。该函数的用户可以选择提供键来请求特定的结果,或者提供单个参数来获得所有的结果。为了在 C++/CLI 中表示这一点,我们需要提供一个带有单个参数的函数的重载版本。这也显示在清单 3-2 中。单参数重载中的代码将调用转发到完整版本,提供一个nullptr
作为第二个参数。这将允许客户端调用函数的单参数版本或双参数版本。
线性回归
LinearRegression
包装器的实现遵循与DescriptiveStatistics
相似的结构。清单 3-3 显示了代码。
-
Listing 3-3The implementation of the LinearRegression wrapper function
对于LinearRegression
函数,有两个数据集。因此,我们为每个数据集执行到一个std::vector<double>
的转换。接下来,我们调用原生 C++ LinearRegression
函数并获得结果包。这些然后被转换成我们选择的托管类型,在这个例子中是一个Dictionary
。注意,我们可以将关键字auto
用于本机 C++ 和托管代码。一般来说,编写 C++/CLI 代码实际上等同于编写本机 C++ 代码。
类型变换
既然我们已经看到了基本的统计函数DescriptiveStatistics
和LinearRegression
,我们来看看类型转换。在 C++/CLI 中,我们不需要显式转换内置类型。在我们的代码中,使用bool
、double
或std::size_t
(或long
)作为参数或返回值的函数不需要显式封送。通常,内置 C++ 类型是在System
名称空间中定义的相应类型的别名。但是,我们确实需要转换以下类型:std::string, std::vector<double>
和std::unordered_map<std::string, double>
。
在Conversion
名称空间中,我们定义了以下两个函数:
void MarshalString(String^ s, std::string& os)
void MarshalString(String^ s, std::wstring& os)
这些是在托管环境中封送字符串的标准函数。函数Marshal::StringToHGlobalAnsi
获取一个String^
并将它转换成一个指向char*
的空终止指针。然后char
被分配给一个std::string
,被分配的内存被FreeHGlobal
释放。该函数的重写版本处理宽字符串的大小写(Windows 上的 UTF-16 Unicode 字符串)。
我们还定义了两个函数来将列表转换成向量。第一个ListToVector
函数将一个泛型List<double>
转换成一个std::vector<double>
。第二个将一个普通的List<String^>
转换为std::vector<std::string>
。原则上,从托管容器(List<double>^
)中复制项目并将其放入非托管 STL 容器是一件简单的事情。初始实现如清单 3-4 所示。
-
Listing 3-4Converting List<double> to std::vector<double>
我们做的第一件事是构造一个空的输出向量。在我们检查输入指针之后,我们获得输入条目的计数,并使用它来确定std::vector<double>
的大小。然后,我们从输入列表中提取每一项,并将其放入输出向量中。字符串的ListToVector
覆盖遵循类似的逻辑,但是使用MarshalString
显式地从托管的string
转换为std::string
。值得强调的是,在这两种情况下,我们都从托管内存(double
s,string
s)中获取实体,并将它们复制到非托管 C++ 内存中。
就性能而言,进行基于元素的复制不太可能是性能最好的方法。有各种方法可以改善这一点。一种可能是利用“牵制”。在正常情况下,CLR 管理与对象关联的内存。除了别的以外,这意味着它可以自由地移动内存。但是,在某些情况下,可能希望能够告诉 CLR 暂时不要移动一些内存。这就是钉扎所实现的。pin_ptr<T>
类型允许您向 CLR 指示,在锁定指针超出范围之前,不应该移动内存。清单 3-5 通过使用std::copy
将cli::array
复制到std::vector
演示了这种方法。
-
Listing 3-5Pinning a CLI array
在这个例子中,我们获得了输入项的计数,并使用它来确定std::vector<double>
的大小,如前所述。然后我们创建一个指向cli::array
的固定指针,并获得指向first
和last
元素的指针。pin_ptr
的目的是确保 GC 在复制操作期间不会移动或删除内存。最后我们使用std::copy
来复制内存块。
我们从使用ListToVector
改为使用ArrayToVector
的目的是为了提高性能。然而,为了做到这一点,我们需要将输入类型从List<double>
改为cli::array<double>
。我们还需要将函数调用改为:
static Dictionary<String^, double>^ DescriptiveStatistics(cli::array<double>^ data);
最后,我们需要改变对类型转换代码的调用,使用ArrayToVector
而不是ListToVector
。如前所述,在转换层需要做出选择。
第二个转换函数将一个std::unordered_map
转换成一个Dictionary
。清单 3-6 显示了代码。
-
Listing 3-6Converting the results package to a Dictionary
从封装在std::unordered_map
中的结果到Dictionary
的转换与之前的转换功能方向相反。我们使用gcnew
实例化一个新的托管Dictionary
。这将返回给我们一个垃圾回收引用。然后,我们迭代地将非托管容器(输入)中的项目放置到托管容器(输出)中。Add
方法接受两个对应于Dictionary
键值对的参数。这个键是一个托管的string
,因此我们需要将std::string
键转换成一个System::String
实例。同样,我们使用gcnew
。托管的String
类有一个构造函数,它接受一个指向char
的本机数组的指针。
我们前面使用的转换函数完全专用于处理 StatsLib 中使用的类型。然而,有许多方法可以使它更通用。例如,将ArrayToVector
转换(清单 3-5 )推广到任何从array<T>
到vector<T>
的转换可能是有用的。或者我们可能更喜欢在std::array<T>
和cli::array<T>
之间转换。我们还可以利用cliext
名称空间提供的功能。另一种可能是使用 C++ 互操作。最后一个值得一提的选择(但我们没有在这里讨论)是使用自己的 c++“object-with-type-information”如果所有的 C++ 类型都可以和类型信息一起封装在一个公共实体中(例如,COM VARIANT
类),那么我们可以通过一些工作来定义这些类型和System::Object
(所有类型的基类)之间的标准转换。网络类型)。这样做的好处是,调用方不需要选择它们转换的类型。为了做到这一点,您需要编写一个 C++ 类来处理内置类型以及复合类型和容器的转换。这不是一项完全无足轻重的任务。这种方法在其他框架中也有使用。例如,Rcpp 使用RObject
*
和 CPython 使用PyObject*
包装原生 C++ 类型。我们将在后面的章节中看到这些例子。这种方法使您可以完全控制如何进行类型转换,也可以完全控制允许的转换集。然而,这可能只有在您有大量类型转换逻辑的情况下才合适。所有这些的主要目的是让您了解转换层可以有多灵活。
异常处理
因为这是一个翻译层,我们需要注意异常。具体来说,我们希望确保从非托管 C++ 层抛出的异常在我们的代码中得到处理,而不是以未翻译的形式传播到使用包装组件的客户端。为此,我们用宏STATS_TRY/STATS_CATCH
包装每个函数调用。清单 3-7 显示了宏的定义。
-
Listing 3-7Exception handling in the managed wrapper
代码捕捉任何std::exception
并创建一个新的类型为InvalidOperationException
的托管异常,将原始异常字符串(经过适当转换)传递给它。因此,C# 客户端将能够查看和处理这些异常。显然,这是可以推广的。标准库定义了更多类型的异常,将这些异常转换成更适当管理的异常类型可能会很有用。
测试代码
StatsCLR.UnitTests
有了这些,我们就可以构建和测试这个库了。为了测试 StatsCLR 组件,我们使用一个名为 StatsCLR.UnitTests 的 C# MSUnitTest 项目。为了简单起见,我们只有一个文件, UnitTests.cs 。这包含了针对DescriptiveStatistics
和LinearRegression
的测试用例,以及针对统计假设测试功能和DataManager
类的测试。这类似于我们在为底层 StatsLib 编写的原生 C++ (GoogleTest)单元测试中使用的方法。清单 3-8 中显示了一种典型的测试方法。
-
Listing 3-8Testing the LinearRegression function
测试函数使用属性[TestMethod]
。这意味着我们可以从 Visual Studio 中的 Test 菜单(以及 Test Explorer 面板)运行它。如前所述,测试遵循安排-动作-断言模式。 3 我们以预期的形式提供数据,并调用LinearRegression
函数。然后将结果与预期值进行比较。此外,我们测试该函数在用空数据调用时会抛出预期的异常。清单 3-9 显示了这方面的代码。
-
Listing 3-9Testing exception handling
和以前一样,我们将此归因于一个[TestMethod]
。此外,我们声明了用我们期望的异常类型参数化的[ExpectedException]
属性。在函数体中,我们声明一个空列表,并将其传递给DescriptiveStatistics
函数。异常是在底层 C++ 层中抛出的,而不是在转换函数中抛出的。类似地,如果我们传入一个空引用,转换函数会将一个空的std::vector<double>
传递给底层的 C++ 函数,后者会抛出相应的异常。作为使用[ExpectedException]
属性的替代方法,我们可以在前面的// Act
部分中编写以下代码:
Assert.ThrowsException<InvalidOperationException>(() => Statistics.DescriptiveStatistics(xs));
这与使用属性获得相同的结果。
托管包装类
介绍
到目前为止,我们已经编写了包装本机 C++ 函数的代码。我们还在标准库类型之间进行了转换。但是,为了从 StatsLib 中公开其余的功能,仍然需要做一些准备工作。我们仍然想要由DataManager
和统计测试类提供的功能。
为了使这一功能可用,我们需要围绕非托管对象编写一个托管包装类。这种方法非常简单。我们定义了一个托管类,它包含一个指向底层非托管 C++ 类型的私有成员变量。然后,我们使用构造函数通过operator new
创建这个非托管类型的新实例。对于我们想要公开的每个基础函数,我们声明一个等效的托管成员函数,它将调用转发到基础类型。该函数负责将参数转换为适当的基础类型。当托管对象被释放时,我们删除指向底层 C++ 类型的指针。这种方法类似于“指向实现的指针”( pimpl )设计模式。我们有不同的类将(托管的)接口与(非托管的)实现细节分开。
数据管理器
DataManager
是一个典型的包装类。它遵循清单 3-10 中所示的 pimpl 习语。
-
Listing 3-10The DataManager wrapper class
DataManager
包装类公开了原生 C++ 类的所有缓存功能。我们可以从类声明中看到,从广义上讲,我们处理的是两个方面。首先,我们管理本机指针Stats::DataManager*
的生命周期。我们使用构造函数和析构函数来完成这项工作。清单 3-11 显示了代码。
-
Listing 3-11Instantiating the native pointer in our wrapper class
构造函数遵循底层类的语义。在这种情况下,没有要传递的参数,所以我们只需创建一个新的实例。析构函数的行为方式与本机 C++ 析构函数类似。它是确定性的,所以当对象超出范围时,析构函数被调用。此外,还有一个由 GC 调用的非确定性终结器。清单 3-12 中显示了这两种析构函数的代码。
-
Listing 3-12The DataManager destructors
从清单 3-12 中,我们可以看到,在 finalizer 中,我们显式地调用了析构函数。C++/CLI 引用类型中的析构函数执行确定性的资源清理。终结器清理非托管资源,可以由析构函数确定性地调用(就像我们在这种情况下所做的那样),也可以由垃圾收集器非确定性地调用。这里重要的是实现类的非托管内存是通过析构函数显式释放的。否则,将会发生内存泄漏。
除了生命周期管理,我们在DataManager
中做的第二件事是转发对底层对象的调用并返回结果。具体来说,我们需要转发调用来检索数据集的计数、添加新的数据集、检索命名的数据集、列出所有带有项目计数的数据集,最后清除所有数据集。清单 3-13 显示了功能GetDataSet
。
-
Listing 3-13The implementation of the GetDataSet function
GetDataSet
函数从本机DataManager
类中检索一个命名数据集。在调用函数之前,我们将 name 参数转换成一个std::string
。数据集作为double
的vector
返回。因此,当我们返回结果时,我们显式地将其转换为List
。任何时候,我们都需要意识到,我们正在充当一个受管理和不受管理的世界之间的边界。因此,我们需要将传入的参数转换为本机类型,当我们获得返回的本机类型时,我们需要将它们转换为托管类型。
TTest 类
TTtest
类类似于DataManager
。在这种情况下,我们选择不公开基类(StatisticalTest
)。我们可以在托管上下文中重新创建层次结构;然而,这是不必要的,所以我们限制自己只公开TTest
类。清单 3-14 显示了完整的类声明。
-
Listing 3-14Class declaration for the TTest
和以前一样,我们在托管类中包装了一个本机指针Stats::TTest*
。在这种情况下,我们模仿原生类的构造语义。每个构造函数使用传递给它的参数实例化适当的本机指针类型。例如,单样本 t 检验的构造函数如清单 3-15 所示。
-
Listing 3-15The TTest constructor for a one-sample t-test
在单样本 t 检验的情况下,我们传入一个double
和一个List<double>
。第一个参数是隐式转换的,而我们使用转换函数ListToVector
显式转换第二个参数。最后,我们创建一个新的非托管TTest
对象,处理任何可能抛出的异常。
正如我们所看到的,即使是这些简单的例子,能够向客户端公开用户定义的类型,而不是编写过程包装函数,提供了一种更丰富的方法来与本机 C++ 代码进行互操作。我们能够更准确地反映底层 C++ 类型的语义,同时允许我们自由选择公开什么。
值得强调的是,我们用相对较少的几行代码完成了什么。我们已经将 C++ 库中的所有功能公开在一个完全可用的包装器组件中,该组件可以在。NET 框架。开发该组件非常简单,尤其是与编写等效的 COM 包装相比。
例如,使用 ATL/COM 构建的 COM 包装需要更多的基础结构。至少,接口和实现需要注册。公开函数和类虽然不难,但比使用 C++/CLI 要复杂得多。我们需要创建新的 COM 接口(例如,IStatisticalTest
,IDataManager,
),这些接口将在 ATL/COM 类中实现。这个类也将提供其他标准接口的实现(例如IDispatch
和IErrorInfo,
)。虽然大量的代码是样板文件,是为我们生成的,但这仍然比使用 C++/CLI ref class
要复杂得多。此外,对于不使用内置类型的函数,我们需要处理与VARIANT
之间的转换。这些并非完全无关紧要。例如,考虑将一个std::unordered_map<std::string, double>
转换成一个连接到VARIANT
的SafeArray
的情况。StatsATLCOM 项目说明了其中的一些问题。 4 总的来说,C++/CLI 包装器提供了一个更简单的选择,同时允许我们使用原生 C++ 开发代码。
摘要
在本章中,我们围绕 StatsLib 构建了一个 C++/CLI 包装器。我们使用这个包装器来公开函数和类,同时在需要的地方转换类型。我们还看到了一个简单的例子,展示了 C# 客户端如何在单元测试项目中使用这一功能。在下一章,我们来看一个使用 Excel 作为客户端的更复杂的场景。
在这么小的空间里,显然有很多东西我们还没有涉及到。我们还没有谈到代表和事件。如果您需要从本机 C++ 环境中使用回调,这些是很重要的。我们提到了“牵制”,但没有包括“拳击”,因为我们试图保持事情简单。在托管和非托管环境中,有许多与内存管理的细节相关的小细节我们还没有涉及到。附加资源部分和参考资料提供了详细介绍这些主题的文档链接。
总的来说,如果您必须将现有代码迁移到。NET 平台。通过编写包装器组件,我们增加了一个间接层,有助于重构底层代码。正如第二章所指出的,我们本可以围绕 StatsLib 编写一个 COM 包装器(同时保持关注点的分离),然后使用 COM 互操作提供的工具直接使用. NET 项目中的组件。然而,正如我们前面提到的,这可能需要更多的基础设施,并导致比使用 C++/CLI 更复杂的体系结构。
额外资源
以下链接提供了本章所涵盖主题的更多详细信息:
-
C++/CLI 语言规范提供了涵盖所有语言特性的最全面的参考文档。可以从这里下载: www。ECMA-国际。org/publications-and-standards/standards/ECMA-372/。
-
如果您需要有关 C++/CLI 语言的其他信息,请参阅 Microsoft 文档”。NET 使用 C++/CLI 编程”是一个很好的起点:
docs。微软。com/en-us/CPP/dot net/dot net-programming-with-CPP-CLI-visual-CPP?view= msvc-160
。 -
关于 C++/CLI 的基本原理的讨论,有 Herb Sutter 的一篇有用的文章,网址是 www . gotw . ca/publications/c++ CLI rational . pdf。
-
有关混合本机程序集和托管程序集的更多信息,以下文档很有用:
docs。微软。com/en-us/CPP/dot net/mixed-native-and-managed-assemblies?view= msvc-160
。 -
名称空间包含了 STL/CLR 库的所有类型。这里是这样描述的:
docs。微软。com/en-us/CPP/dot net/STL-clr-library-reference?view= msvc-160
。 -
使用 C++ 互操作在这里描述:
docs。微软。com/en-us/CPP/dot net/how-to-marshal-arrays-using-CPP-interop?view= msvc-160
。 -
有关 C++/CLI 中析构函数和终结器的更多信息,请参见以下内容:。微软。com/en-us/previous-versions/visual studio/visual-studio-2008/ms 177197(v = vs . 90)?redirectedfrom= MSDN 。
-
关于“装箱”和“固定”的全部细节可以在
docs 找到。微软。com/en-us/CPP/dot net/boxing-CPP-CLI?view= msvc-160
和docs。微软。com/en-us/CPP/extensions/pin-ptr-CPP-CLI?view= msvc-160
。
练习
本节中的练习提供了一些使本机 C++ 功能可用于的实践。NET 客户端通过 C++/CLI 包装组件。
我们对 C++ 层做了三个主要的改变,我们有兴趣公开:
-
我们向
LinearRegression
函数添加了更多的结果。 -
我们添加了一个执行统计 z 测试的
ZTest
类。这类似于TTest
类,所以我们可以在此基础上创建任何新代码。 -
我们添加了一个
TimeSeries
类,带有一个计算简单移动平均值的函数。
1)在 StatsCLR 中。UnitTests 项目,扩展测试方法TestLinearRegression
以包含相关系数 r 和r2 度量的测试。重建并重新运行测试,确认没有错误。
2)在 StatsCLR 项目中,公开 z 测试功能。虽然完全可以为 Statistics.h 和 Statistics.cpp 添加用于ZTest
功能的过程包装器,但是最好创建一个托管的ZTest
类来包装底层的本地 C++ 类并调用Perform
和Results
函数。这种方法在托管包装类一节中有描述。
所需的步骤如下:
-
在 StatisticalTests.h 中增加
public ref class ZTest { };
。遵循用于TTest
包装器的类定义。 -
在 StatisticalTests.cpp 中,添加类实现。构造函数需要调用底层 C++ 构造函数的适当版本。现有的转换函数可用于将构造函数参数转换为标准库类型。
Perform
和Results
的实现只是将调用转发给底层的非托管本地实例。结果需要转换成一个托管的Dictionary
。同样,这可以使用现有的转换函数来完成。 -
重新构建 StatsCLR 项目。在 StatsCLR 中。单元测试,为我们处理的三种类型的 z 测试添加测试用例:
TestSummaryDataZTest
、TestOneSampleZTest
和TestTwoSampleZTest
。这些类似于本地 C++ 类的测试用例,可以使用相同的数据。检查测试用例运行是否没有错误。
3)公开TimeSeries
类。这与前面的练习类似,但更复杂一些,因为我们需要添加一个转换函数来处理日期的输入列表,并添加另一个转换函数来返回移动平均值的列表。
所需的步骤如下:
-
将文件 TimeSeries.h 和 TimeSeries.cpp 添加到项目中。
-
在头文件中,添加包含本机 C++ 类声明的行:
-
使用声明添加以下内容:
using namespace System; using namespace System::Collections::Generic; using namespace System::Text;
-
Add the class definition with appropriate C++/CLI types. In the case of the “dates,” the native C++ class treats these as
long
s. While we could just have a list oflong
s, in .NET theDateTime
class is more useful. We should be able to make use of the.ToOADate
member function to get a serial number. The suggestedTimeSeries
constructor arguments are thereforeList<System::DateTime>^ dates, List<double>^ observations
MovingAverage
成员函数应该返回一个List<double>^
。 -
在实现文件中,添加
#include "..\include\TimeSeries.h"
- 添加类定义(构造器、析构器、终结器和
MovingAverage
函数)。对于构造函数参数List<System::DateTime>^ dates
,我们需要添加一个转换函数:
#include "pch.h"
#include "Conversion.h"
#include "TimeSeries.h"
- 对于
MovingAverage
函数,我们可以将window
参数直接转发给非托管类实例。然而,我们需要将返回的vector
转换成一个List
:
std::vector<long> ListToVector(List<System::DateTime>^ dates);
-
在
Conversion
名称空间中,添加以下函数声明:std::vector<long> ListToVector(List<System::DateTime>^ dates); List<double>^ VectorToList(const std::vector<double>& input);
-
在文件 Conversion.cpp 中,添加实现。这些类似于现有的转换函数。
-
重新构建 StatsCLR 项目。在 StatsCLR 中。UnitTests,添加一个名为
TestMovingAverage
的测试方法。这将类似于本地 C++ 类的测试用例。检查测试用例运行是否没有错误。
List<double>^ VectorToList(const std::vector<double>& input);
四、C# 客户端:使用托管包装
介绍
在本章中,我们来看看如何使用 StatsCLR,这是我们在前一章中构建的 C++/CLI 组件。在某种程度上,我们已经通过 StatsCLR 做到了这一点。单元测试测试模块。然而,在这一章中,我们将更广泛地研究在各种不同的环境中使用组件。
我们的第一个应用程序是一个普通的 C# 控制台应用程序。当考虑如何使用包装组件时,我们有三个主要目标。首先,我们希望检查组件是否正常工作,以及我们是否可以调用函数并获得预期的结果。因此,最初,我们只是在一个简单的环境中测试一些我们已经公开的功能。其次,我们想知道我们是否可以将该组件与其他组件一起使用。开发组件的一个主要原因是利用其他。净成分。为此,我们使用 Accord.NET 来研究统计函数如何适合简单的机器学习(ML)流水线。最后,我们希望检查组件是否实现了作为. NET 组件的可用功能。为此,我们看反射。
我们的第二个应用程序更侧重于现实世界。我们将组件连接到 Excel,并使用 StatsLib 中的基本统计功能。
统计客户
项目设置
StatsClient 是作为 C# 控制台应用程序构建的。唯一感兴趣的设置是平台目标。在用于构建的项目属性下,我们将平台目标设置为“x64”。这与底层 StatsLib C++ 库和 StatsCLR C++/CLI 组件是一致的。如果我们不这样做,我们将得到一个关于处理器架构和项目不匹配的编译器警告,更重要的是,当我们运行可执行文件时,程序集将无法加载,并出现一个模糊的错误消息。我们还在项目引用节点下添加了对 StatsCLR 项目的引用。
安装 Accord.NET
Accord.NET(http://accord-framework.net/
)是一个. NET ML 框架。它有各种各样的模块,但我们仅限于使用核心 ML 模块、控制模块和 IO 模块。我们已经使用包管理器将它们安装到 StatsClient 中。依赖项列在文件 packages.config 中。您可以在工具➤ NuGet 包管理器➤管理 NuGet 包下查看解决方案...如果软件包需要安装或更新。
代码组织
StatsClient 项目中的代码被组织到六个文件和主Program
类中。下面列出了这些问题,并对每个问题进行了简要描述:
-
StatisticalFunctions.cs 使用
DataManager
包装器执行描述性统计和线性回归函数。 -
DataAnalysis.cs 对一些时序金融数据进行简单的分析。
-
data modeling . cs根据一个单一的特征——房子大小——为房价创建一个简单的线性数据模型。
-
HypothesisTest.cs 执行双样本学生 t 检验,并报告备选假设。
-
演示了从程序集中提取模块信息。
-
演示了程序集的动态加载和函数的动态调用。
-
Program.cs 包含调用示例代码的主例程。
统计功能
StatisticalFunctions.cs 中的代码运行一个典型的会话。我们首先创建数据管理器的一个实例,然后向其中添加两个数据集,分别命名为“xs”和“ys”。之后,我们检索“xs”数据集的描述性统计信息,并将结果输出到控制台。如图 4-1 所示。
图 4-1
测试DescriptiveStatistics
功能
接下来,我们使用最初加载的相同数据集执行线性回归,并输出结果。会话的最后一部分包括创建 t-test 类的实例。我们传入对应于汇总数据 t-test 的构造函数参数,然后调用Perform
并检索结果,将它们显示在控制台上。
数据分析
DataAnalysis.cs 模块将DescriptiveStatistics
函数与 Accord.NET 的其他统计分析函数结合使用。该例程的目的是分析从 1999 年 1 月 1 日到 2017 年 12 月 29 日欧元/美元汇率的收益分布。代码首先读入每日欧元/美元汇率的数据。读取数据后,我们显示时间序列。使用我们的DescriptiveStatistics
函数,我们请求一些汇总度量(最大值,表示,中值,最小值, StdDev)。美国,Q1 , Q3 )描述退货如下:
List<double> _returns = new List<double>(returns);
List<string> stats = new List<string>() { "Maximum", "Mean", "Median", "Minimum", "StdDev.S", "Q1", "Q3" };
var results = Statistics.DescriptiveStatistics(_returns, stats);
接下来,我们计算分位数并输出每日回报的分布。我们可以很容易地进一步执行一些功能工程和建模。输出示例如图 4-2 所示。
图 4-2
分析欧元/美元的回报分布
数据建模
在模块data modeling . cs中,我们使用 StatsCLR 组件作为 ML 管道的一部分。代码如清单 4-1 所示。
-
Listing 4-1Modelling house prices
前两个阶段包括导入训练数据和准备输入到模型中。数据集是一个小的房价数据集,由两列组成:大小和价格。为了这个模型的目的,我们简单地假设房价是由一个单一的特征决定的:大小(确切地说是以平方英尺为单位的建筑面积)。更复杂的模型会使用更多的功能,并试图确定哪些功能对价格影响最大。然后,我们使用我们的LinearRegression
函数来拟合模型。最后,我们提取系数,以便基于新的输入大小来预测价格。一个更广泛的例子是将数据集分成训练部分和测试部分,并使用测试部分来确定模型拟合的好坏。在这个简单的演示中,我们忽略了这些问题。我们还将该模型与使用 Accord.NET 生成的OrdinaryLeastSquares
回归模型进行了比较。结果和预期的一样。
假设检验
在模块 HypothesisTest.cs 中,我们对美国和日本的汽油消耗数据( us_mpg.csv , jp_mpg.csv )进行双样本 t 检验(假设方差相等)。在读取数据并将其转换成我们可以使用的格式后,我们执行 t-test 并报告结果。如图 4-3 所示。样本 1 对应于美国汽油消耗数据,样本 2 对应于日本汽油消耗数据。
图 4-3
美国与日本 mpg 双样本 t 检验的假设检验报告
从图 4-3 可以看出,结果表明样本均值在统计上有显著差异。也就是说,来自美国和日本的汽车通常不会消耗相同的汽油。事实上,测试表明美国的汽车耗油量更高。这种差异是由于偶然因素造成的可能性极小。因此,我们不能拒绝替代假说。
除了我们可以轻松地将 StatsCLR 组件添加到我们的应用程序中并调用底层 C++ 函数之外,这里没有什么令人惊讶的。然而,如果我们后退一步,考虑在这个相同的应用程序中,我们已经放弃了其他。净成分。这使我们能够利用大量现有的功能。另一方面,我们同样可以在不同的项目类型中添加对 StatsCLR 的引用:可能是一个 WPF 应用程序,或者是一个用 ASP.NET 编写的 web 应用程序。
使用反射
StatsCLR.dll是一个. NET 组件,更确切地说,是一个程序集。在高层次上,程序集是任意的。NET 软件组件。程序集可以放在一个 dll 、 lib、或 exe 中。有两种主要类型的程序集:私有和共享。共享程序集要求在全局程序集缓存(GAC)中注册。这个主题在生产系统中很重要,但超出了本书的范围。在本章中,我们只处理一个单独的私有程序集。就分发而言,关于私有程序集的重要一点是,它必须位于使用它的可执行文件所在的同一目录(或子目录)中。 1
程序集与传统的 dll 共享许多功能,但是有一些重要的不同,特别是在注册和版本控制方面。从我们的角度来看,最重要的区别是程序集包含描述它所包含的类型和函数的元数据,并且这是容易获得的。可以使用工具(例如,中间语言反汇编工具ildasm.exe
)或编程来探索汇编。
正如我们所说的,程序集包含元数据、编译代码和资源。我们可以使用反射来检查这些元数据。那个。NET framework 提供了一个 API,允许我们枚举程序集中的模块和模块中的类型。我们还可以枚举给定类型的方法、字段、属性、事件和构造函数,以及方法的参数。此外,我们可以动态地加载一个程序集,创建一个对象,并在运行时调用一个方法。在本节中,我们将演示这两种工具。
模块信息
ModuleInfo.cs 中的代码演示了从一个程序集获取类型信息。这可能是当前程序集,也可能是我们显式加载的程序集。类ModuleInfo
有一个函数Enumerate,
,它向控制台输出主可执行文件或命名程序集的详细信息。如清单 4-2 所示。
-
Listing 4-2Exploring type information in an assembly
一旦我们有了对程序集的引用,我们就可以获得模块列表。然后,对于每个模块,我们列出了它支持的类型和方法。前面的代码构建了一个包含函数名和每个方法的参数的functionString
。调试时快速浏览一下“监视”窗口,可以看到对该模块有丰富的描述。如图 4-4 所示。
图 4-4
“监视”窗口中的导出类型
在这种情况下,我们可以看到我们在这个项目中声明的类。我们可以更进一步,扩展导出的类型来发现方法和它们的参数。
动态调用
接下来,dynamic initial . cs中的代码,列出了 4-3 ,展示了动态对象调用和动态对象创建。
-
Listing 4-3Invoking the LinearRegression function dynamically
代码的第一部分(未显示)加载 StatsCLR 程序集。从上一节我们知道,我们可以通过组装模块获得类型。在这种情况下,除了我们在 StatsCLR 中声明的类型之外,还有大量的类型。这是因为这些类型包括 C++/CLI 代码中使用的 STL 类型。一个有用的方法是使用对象浏览器。正如我们在上一节中看到的,我们可以检查方法、参数和字段。但是,在这种情况下,我们只想调用LinearRegression
函数。为此,我们首先获得代表Statistics
类的Type
。由此,我们获得了一个表示LinearRegression
方法的方法对象。我们用数据列表构造了一个parameters
对象数组。最后,我们调用method.Invoke
。像往常一样,我们检索结果并显示它们。
我们的最后一个例子是动态对象创建。清单 4-4 显示了代码。
-
Listing 4-4Creating an object dynamically
我们开始设置对应于汇总数据 t-test 的输入参数数组。然后我们从 StatsCLR 程序集获得对应于TTest
类的类型。有了这些信息,我们可以创建一个TTest
类的实例。为此,我们使用了Activator.CreateInstance
函数。这将动态创建一个TTest
类的实例。参数确定调用哪个基础构造函数。在执行 t-test 之后,我们检索结果并显示它们。
管理员
如果这一切看起来有点做作,我们可以使用 PowerShell 来演示一个更真实的例子。清单 4-5 显示了一个简单的 PowerShell 脚本,它执行 StatsCLR 中的一些功能,以便分析系统性能计数器。
-
Listing 4-5Using StatsCLR to analyse performance counters
最初,该脚本加载 StatsCLR 程序集(就像我们之前所做的一样)。我们显示了可供参考的类型。接下来,我们声明一个double
的数组(使用 C# Collections.Generic.List
类型)。它保存了调用名为“使用中的内存提交字节百分比”的性能计数器的结果。我们使用名为Get-Counter
的 CmdLet 来检索关于指定计数器的数据。对于这个计数器,我们只要求 10 个样本,观察间隔为 1 秒。然后我们为我们想要的统计数据设置键(表示, StdDev)。S 、最小值和最大值,并调用DescriptiveStatistics
。输出可以以多种方式格式化,也可以通过管道传输到其他cmdlet。在这种情况下,我们选择将表格显示输出到控制台,并将表格输出到默认的网格视图。如图 4-5 所示。
图 4-5
性能计数器数据的 PowerShell 显示
在这一节中,我们仅仅触及了反射所能实现的表面:如果我们能够动态地加载程序集,那么我们就可以在执行时决定使用哪个版本的程序集。或者,例如,我们可以将不同的程序集与接口的实现相关联,从而按需加载不同的实现。更一般地,就软件设计而言,这些设施(动态加载和动态方法调用)使得能够开发松散耦合、可重用的软件组件系统,这有助于软件的互操作性。
StatsExcel
我们的第二个 C# 客户机稍微复杂一些,当然也比上一节中的例子更实际。我们的目标是能够在 Excel 中使用 StatsLib 提供的功能。更具体地说,我们希望能够像这样调用 Excel 工作表中的函数:=StatisticalFunctions.LinearRegression(xs, ys)
。
让 Excel 使用 C/C++ 组件已经存在很长时间了。在这一时期,有许多略有不同的方法来实现这一点。一种典型的方法是手工制作一个 xll 。一个 xll 是一个 C/C++ dll 外壳,带有一些 Excel API 定义的附加导出函数。特别是,当 XLL 被添加到 Excel 中时,Excel 将调用函数xlAutoOpen
。然后xlAutoOpen
例程将每个函数注册到 Excel 中。一旦注册,这些函数就会出现在 Excel 的函数列表中。编写一个 xll 的替代方法是使用一个 COM 加载项(这可以是一个非常灵活的 Office 扩展加载项,也可以是一个 VSTO(Visual Studio Tools for Office)加载项,这在某种程度上更受限制)。无论是哪种情况,都有一定的准备工作要做。也许基于 COM 的方法的最大问题是需要注册组件。
我们为这个组件采用的方法稍微容易一些。我们使用的是第三方库,Excel-DNA ( https://excel-dna.net/
),可以让我们轻松连接 Excel。Excel-DNA 框架利用元数据和反射来动态创建一个 xll 。
安装 Excel-DNA
我们已经使用包管理器将 Excel-DNA 安装到 StatsExcel 项目中。您可以在工具➤ NuGet 包管理器➤管理 NuGet 包下查看解决方案...如果软件包需要安装或更新。Excel-DNA NuGet 包安装所需的文件并配置项目以构建 Excel-DNA 插件。
项目设置
StatsExcel 组件是作为 C# 类库构建的。在项目属性中,检查外部程序是否正确引用了 Excel。“启动外部程序”的设置要设置成指向 Excel,例如C:\ Program Files \ Microsoft Office \ root \ Office 16 \ Excel。EXE 。此外,您可以检查命令行参数是否引用了我们想要构建的 x64 目标:" stat excel-addin 64 . xll "。
向 Excel 公开函数
为了使 StatsCLR 组件中的函数可用,我们需要编写额外的包装函数,并将它们声明为public static
函数。组件构建完成后,这些函数被导出到 Excel 中,并出现在可用函数列表中,如图 4-6 所示。
图 4-6
通过 Excel-DNA 从 StatsCLR 公开的统计函数
从图 4-6 中,我们可以看到列出的DescriptiveStatistics
、LinearRegression,
和统计测试函数。我们还可以看到描述所选功能的帮助字符串。在文件 StatisticalFunctions.cs 中,我们声明了一个类,它包装了我们想要使用的函数。我们包装了DescriptiveStatistics
、LinearRegression
和TTest
类,只是为了让事情易于管理。清单 4-6 显示了DescriptiveStatistics
功能。
-
Listing 4-6The DescriptiveStatistics function
从清单 4-6 中,我们可以看到函数是由ExcelFunction[]
属性描述的。我们可以提供一个Name
、一个Description,
和一个HelpTopic
参考。从 Excel 的角度来看,函数是由Name
属性中的值调用的。所以这里叫"StatisticalFunctions.DescriptiveStatistics"
。我们试图保持名称空间和函数名称的唯一性,因为我们希望与 Excel 中的其他函数共存。除了名称之外,还有许多属性可以用来描述我们的函数。具体来说,我们使用ExcelArgument[]
属性来描述参数。在这种情况下,函数接受一个 doubles 数组(double[] data
)和一个 string keys 数组,我们将它们作为对象传递(object[] keys
)。该函数返回一个数组(object[,]
)。
类型变换
一般来说,我们需要将 Excel 类型转换成包装函数能够理解的类型。因为我们决定用List<double>
作为数据,所以我们利用了。NET framework 允许我们直接从一个array
构造List
,因此在转换代码中要做的工作更少。对于键,我们被传递一个对象数组,它可以包含字符串和其他数据。因此,我们需要正确地转换按键。清单 4-7 展示了我们如何做到这一点。
-
Listing 4-7Converting an object array of keys to a list of strings
该函数首先构造一个空的List<string>
。然后我们检查输入键数组是否有效。如果是这样,我们迭代输入键,将它们转换成字符串。如果转换失败,将引发异常。我们在下面处理异常。如果键不为空,我们将它添加到列表中,并在完成时将其返回给调用者。
对于来自DescriptiveStatistics
的返回值,我们需要将结果Dictionary<string, double>
转换成一个两列的对象数组(object[,]
)。在我们的例子中,它将在第一列包含string
s,在第二列包含double
s。清单 4-8 显示了转换函数。
-
Listing 4-8Converting the package of results into a two-column array
第一行使用结果参数的keys
集合的维度创建一个对象数组。然后我们遍历结果项,提取每个键值对,并将它们分配给对象数组。然后将它返回给调用者。
构建并运行
项目应该在没有警告或错误的情况下生成。如果您将 StatsExcel 项目设置为启动项目,那么您将能够立即运行代码。Excel 将启动,我们生成的 xll 将被加载。如果要在 Visual Studio 解决方案之外运行外接程序,请双击位于\ stat excel \ bin \ Debug或\ stat excel \ bin \ Release目录中的文件。注意,因为 StatsExcel 项目引用 StatsCLR 项目,所以StatsCLR.dll被复制到这个目录中。第一次运行该外接程序时,Excel 将提示您确认是否要为此会话启用该外接程序。如果希望在以后的运行中避免出现此提示,可以使用“选项”菜单中的 Excel 信任中心,将外接程序的路径设置为受信任的位置。
Excel 启动后,打开文件..\Data\StatsCLRTest.xlsx 。这个文件包含两个工作表:“基本功能”测试了DescriptiveStatistics
和LinearRegression
功能。对于DescriptiveStatistics
,我们定义了两个范围‘xs’和‘ys’,并使用它们来计算描述性统计数据。将结果与硬编码值进行比较。如果您更改其中一个输入值,您应该会观察到一些测试失败。我们还通过传入单个标签并将结果与等效的 Excel 函数进行比较来测试DescriptiveStatistics
函数。最后,我们测试了LinearRegression
函数。如图 4-7 所示。
图 4-7
Excel 中的LinearRegression
函数
在结果下面,我们绘制了数据并添加了趋势线。我们可以看到,图表上显示的回归方程与我们获得的截距(b0
)和斜率(b1
)系数相同。
“统计测试”工作表练习了TTest
功能。我们检查汇总数据的 t 检验,以及单样本和双样本 t 检验。在后两种情况下,我们显示箱线图来表示各个数据集均值的差异。在这两种情况下,很明显,各自的均值明显不同,这一点通过 t 检验的pval
得到了证实(这种差异可能是偶然观察到的)。
异常处理
我们应该小心确保异常不会从我们的代码传播到 Excel 中,因为这可能会导致 Excel“崩溃”,这是非常不希望的。我们知道 C++ 层在某些情况下会抛出异常,特别是在数据不存在或者没有足够的数据来执行操作的情况下抛出std::invalid_argument
。我们还知道,在 StatsCLR 层,这些已经被转换为System::InvalidOperationException
,并将被重新抛出。因此,在 StatsExcel 层,我们应该处理这些异常。
实际上,Excel-DNA 在以通用方式处理异常方面做得很好——它在单元格中显示一个#VALUE
。在我们的例子中,我们希望显示字符串信息。在正常情况下,我们将函数调用的结果返回到一个object
数组中。我们利用了 Excel 可以处理返回的object
数组中的字符串和数字数据这一事实。当抛出异常时,我们将异常的结果赋给object
数组。为此,我们使用函数ReportException
,如清单 4-9 所示。
-
Listing 4-9Reporting an exception
当抛出一个异常时,我们创建一个 1x2 数组并用异常信息填充第二列。如果我们尝试不带参数调用StatisticalTests.TTest
函数,我们会看到以下输出:
"Error in function boost::math::students_t_distribution<double>::students_t_distribution: Degrees of freedom argument is -1, but must be > 0 !"
结果并不漂亮,但它是有益的。值得注意的是,如果您调用带有空参数的函数,您将返回#VALUE!
s。如果您调用带有空数组(没有数据的单元格)的函数,这将作为初始化为 0 的double
s 的数组传递,这在某些函数的情况下是可以接受的。例如,如果传递给底层 C++ 函数GetDescriptiveStatistics
的数据点太少(计算峰度至少需要四个数据点,计算偏斜至少需要三个数据点),就会得到一条关于“数据不足以执行操作”的消息,这是 C++ 层抛出的正确异常。
排除故障
最后,简单说说调试。从 Visual Studio 内部调试并不比在我们想要调试的行上设置断点更困难。这允许我们查看传递到函数中的参数,并检查参数的类型和维度是否与函数签名相对应。这里的不匹配是运行时错误的常见来源。此外,我们可以在“项目设置”“➤调试”下设置“启用本机代码调试”标志,以允许我们调试到本机 C++ 层(从 Excel)。这样,我们可以检查我们返回到 Excel 的值是否对应于函数签名中声明的返回类型。任何问题都可以迅速得到调查和解决。
摘要
在这一章中,我们展示了一些涉及 StatsCLR 组件的用例。我们首先看了一个基本的 C# 控制台应用程序。除了简单地测试统计函数和类,我们还引入了 Accord.NET 的功能。这些例子可能显得虚假和简单。然而,重要的一点是,我们能够利用不同的功能。除了我们的以外。我们还看到了如何使用反射来动态检查程序集的内容。这也证明了 StatsCLR 组件是“合适的”。NET 程序集。这意味着我们可以完全参与。NET 类型系统。这反过来也方便了其他客户使用我们的库。最后,我们使用 Excel-DNA 将 StatsCLR 包装器连接到 Excel。除了允许我们利用 Excel 作为宿主环境之外,我们组件还可以无缝地与其他组件进行互操作。净成分。
额外资源
以下链接提供了本章的一些附加背景信息:
-
中有关程序集的更详细的讨论。NET,推荐以下文章:
https://docs.microsoft.com/en-us/dotnet/standard/assembly/
。 -
下面的文档详细描述了。NET 运行时定位程序集:
https://docs.microsoft.com/en-us/dotnet/framework/configure-apps/specify-assembly-location
。 -
有许多方法可以让 Excel 与 C++ 通信。开发 Excel XLLs 大概是最基础的,这里详细介绍:
https://docs.microsoft.com/en-us/office/client-developer/excel/developing-excel-xlls
。其他方法包括 VSTO (https://docs.microsoft.com/en-us/visualstudio/vsto/getting-started-programming-vsto-add-ins?view=vs-2019
)和 Office 插件。 -
Excel-DNA 文档可以在这里找到:
https://docs.excel-dna.net/
。推荐“入门”文档(https://docs.excel-dna.net/getting-started/
)。
练习
下面的练习涉及 StatsExcel 客户端,以及它如何使用我们通过 StatsCLR 组件公开的新功能,以及我们在本机 C++ 代码中透明提供的功能。
1)更新 StatsCLRTest.xlsx 表,显示并检查LinearRegression
系数的新值。
-
将
SS_yy
、r
和r2
添加到输出中(根据需要调整数组大小)。 -
更新测试结果,使其全部通过。
2)在 StatsExcel 项目中,添加函数来执行 z 测试。我们以 t-test 函数为例,最终得到三个新函数:StatisticalFunctions.SummaryDataZTest
、StatisticalFunctions.OneSampleZTest
和StatisticalFunctions.TwoSampleZtest
。
所需的步骤如下:
-
克隆现有的功能并对其进行调整。
-
向 StatsCLRTest.xlsx 添加新工作表(或复制现有的统计测试工作表)。使用具有预期值的数据集测试函数。
3)给 StatisticalFunctions.cs 添加一个名为StatisticalFunctions.MovingAverage
的函数。这需要三个参数:一个日期数组、一个观察值数组和一个窗口大小参数。如前所述,这个函数有点复杂,因为我们需要处理从double
s(由 Excel 提供)到我们的函数所期望的DateTime
对象列表的转换。我们还需要将得到的移动平均序列作为一个单列的double
数组返回。
在添加MovingAverage
功能之前,添加转换代码。第一个函数将数组double
(代表日期)转换成一个List<DateTime>
。这可以通过以下方式实现:
public static List<DateTime> ToDateTime(double[] dates)
{
List<DateTime> output = new List<DateTime>();
for (int i = 0; i < dates.Length; ++i)
{
output.Add(DateTime.FromOADate(dates[i]));
}
return output;
}
第二个函数将List<double>
结果包转换成一列object
数组。这可以通过以下方式实现:
public static object[,] ResultsToObject(List<double> results)
{
object[,] o = new object[results.Count, 1 /* column */];
for (int i = 0; i < results.Count; ++i)
{
var val = results.ElementAt(i);
if (Double.IsNaN(val))
o[i, 0] = ExcelDna.Integration.ExcelError.ExcelErrorNA;
else
o[i, 0] = val;
}
return o;
}
准备好之后,添加MovingAverage
函数。原型如下:
[ExcelFunction(Name = "StatisticalFunctions.MovingAverage",
Description = "Compute a moving average from a set of data.",
HelpTopic = "")]
public static object[,] MovingAverage(
[ExcelArgument(Description = "Array of dates")] double[] dates,
[ExcelArgument(Description = "Array of observations")] double[] observations,
[ExcelArgument(Description = "Window")] int window
)
{
// ...
}
-
将输入参数转换成
TimeSeries
类能够理解的类型。创建一个 C++/CLITimeSeries
类的实例,并向其传递参数。请求一个MovingAverage
,并将结果返回到对象数组中。记得处理异常。 -
重新构建 StatsExcel 项目并测试新的
MovingAverage
函数。添加名为“时间序列”的新工作表。使用我们在单元测试中使用的相同数据。在 Excel 中测试函数。Excel 有自己的移动平均计算(在数据分析下)。 2 将 Excel 生成的值与此实现进行比较。它们应该是相同的。
五、构建 R 包
介绍
在这一章和下一章中,我们将简单的 C++ 统计函数库连接到 R 上。然后,我们使用这个包装组件来公开我们想要的功能。本章着重于项目设置和用 RStudio 构建包的机制。下一章将重点介绍使用 Rcpp 作为连接 C++ 和 r 的框架的细节。
这种情况下的项目设置比以前稍微复杂一些。总的来说,我们需要构建一个 CRAN 1 包的标准环境。更具体地说,开发环境需要使用合适的编译器。因为不同 C++ 编译器(GCC、MSVC 等)产生的输出不尽相同,所以不可能混合不同编译器产生的目标代码。从我们狭隘的从业者的角度来看,结果是我们需要用不同的编译器/链接器构建一个版本的 C++ 统计函数库。具体来说,GNU 编译器集合(gcc)必须与 C++ 语言对应的 g++ 编译器(GCC)一起使用。这是为了构建一个 ABI(应用程序二进制接口)兼容的组件,该组件将驻留在 R 中,并在 R 环境中进行互操作。
所涉及步骤的简要概述如下:
-
安装所需的 gcc 工具。
-
从与之前相同的源代码中设置并构建一个新的静态库(使用代码块)。库输出是 libStatsLib.a 。
-
在 RStudio ( StatsR )中创建 Rcpp 项目。
-
配置 Rcpp 项目以使用新的静态库。
这将为我们提供一个有效的包装外壳。稍后,我们将了解如何添加功能。与前几章不同,本章我们更关注工具链(代码块、Rtools 和 RStudio)。我们把编写 Rcpp 层、构建和测试功能留到下一章。
先决条件
工具
Rtools 是一套在 Windows 上构建 R 包的工具,包括 gcc 编译器。Rtools 的安装程序从 https://cran.r-project.org/bin/windows/Rtools/
开始提供。应该安装 64 位版本的 Rtools:Rtools 40-x86 _ 64 . exe。需要注意的是,要安装 Rtools 4.0,你需要 4.0.0 或更高版本的 R。完成安装后,确保将RTOOLS40_HOME
环境变量设置为 rtools 目录。另外,将 rtools 目录添加到PATH
环境变量中。也可以使用命令:install.Rtools()
从 RStudio 内部直接安装 Rtools。这将安装最新版本的 Rtools。以下链接给出了如何操作的说明: https://rdrr.io/cran/installr/man/install.Rtools.html
。要检查 Rtools 是否已正确安装,请打开 PowerShell 提示符并键入gcc --version
以显示程序版本信息。
安装代码块
实际上,安装代码块并不是先决条件,这只是一种便利。安装程序可从 www.codeblocks.org/downloads/binaries
获得。我们的目标是用 gcc 工具链构建一个符合 ABI 标准的静态库,有几种方法可以实现这个目标。如果您习惯于使用 makefiles 手动构建库,或者您更喜欢使用 CMake 来配置使用 gcc 工具链的构建环境,那么您不需要使用代码块。附录 B 包含关于配置 Visual Studio CMake 项目 StatsLibCM 以构建我们需要的库输出的基本说明。另一方面,如果您更喜欢将 Visual Studio 代码作为您的 C++ 开发环境,也可以使用 MinGW 将它配置为与 GCC 一起工作。有关更多信息,请参见本章末尾的附加资源部分。
因为已经使用 gcc 为跨平台 C++ 开发配置了 CodeBlocks,所以我们将在这里继续使用它。此外,CodeBlocks 提供了多种有用的项目类型和几个构建目标(例如,静态链接库、动态链接库)。此外,调试支持(包括设置断点和观察变量)比从控制台会话使用 gdb 更容易。
代码块
工具链设置
打开代码块。转到设置➤编译器...。在选定的编译器下,选择 GNU GCC 编译器,并配置以 C++17 为目标的 g++ 版本。图 5-1 显示了全局编译器设置。
图 5-1
代码块中的编译器设置
除了常规设置(如图 5-1 所示),还有许多控制编译方面的有用选项。具体来说,有用于调试、分析、警告、优化和 CPU 架构的选项。对于这个项目,我们不使用这些选项中的任何一个,但是知道它们的存在是有用的。
接下来,在 Toolchain executables 选项卡中,单击 Auto Detect 按钮。这个要填写到编译器安装目录的路径,例如,D:\ Program Files \ mingw-w64 \ x86 _ 64-8 . 1 . 0-POSIX-seh-rt _ V6-rev 0 \ mingw 64。如果不是这种情况,请点击“..”按钮,手动选择 MinGW 目录(gcc 工具所在的目录)。请注意,CodeBlocks 本身会安装 MinGW 工具集。所以,除了 Rtools 之外,你还可以安装第二个 MinGW。我有两个版本的 MinGW 软件包——一个是 CodeBlocks 的 gcc 8.1.0,另一个是 Rtools 的 gcc 8.3.0。这不会造成问题,因为两者的输出都是 ABI 兼容的。来自 CodeBlocks 的 MinGW 安装将目录放入PATH
环境变量中,所以这是我们用来构建的。但是,如果您愿意,可以将其更改为使用 Rtools 的路径。
如图 5-2 所示,填入剩余的工具。
图 5-2
工具链可执行文件
最后,在搜索目录选项卡下,添加到 boost_1_76_0 目录的路径。图 5-3 显示了我们使用的设置。
图 5-3
设置搜索路径以使用 Boost
完成配置后,按 OK 保存所有更改。
项目设置
StatsLibCB 目录包含 CodeBlocks 项目文件( StatsLibCB.cbp )。该项目使用静态库模板。静态库基于与前面相同的 C++ 源代码,位于\公共目录中。在代码块中打开项目。右键单击项目节点,选择项目属性,如图 5-4 所示。
图 5-4
项目设置
整个项目设置非常简单。该页面(图 5-4 )给出了与目标文件生成、 pch 文件、平台和执行目录相关的选项。我们在这里没有做任何更改。选择构建目标页面,检查(调试和发布)构建目标如图 5-5 所示。
图 5-5
为 libStatsLib 生成目标
查看图 5-5 ,我们可以看到项目的类型被设置为静态库。输出文件名为 libStatsLib.a (均为调试和发布)。在图 5-5 的底部,我们可以看到我们已经添加的构建目标文件。单击确定保存设置。项目环境应该如图 5-6 所示。
图 5-6
包含源文件和包含文件的项目节点
根据 StatsLibCB 工作区节点的显示方式(右键单击该节点可获得各种选项),项目文件的视图可能会略有不同。在这个阶段,项目已经可以构建了。从“生成”菜单中,选择“生成”(Ctrl+F9)。构建该库的调试版本和发布版本。“生成日志工具”窗口显示传递给编译器和链接器的命令行。项目应该在没有警告或错误的情况下构建,库( libStatsLib.a )应该位于与所选构建目标相对应的输出目录中。
R/RStudio 包
背景
对于这一部分,您需要启动并运行 RStudio。RStudio 是托管 R 环境和使用 R 语言开发应用程序的首选 IDE。我们可以使用更基本的 RGui 然而,RStudio 提供了更好的工具,特别是在构建 R 包的时候。因此,在成功构建了符合 ABI 标准的统计库之后,我们现在准备创建一个使用它的 R 包。
在 Windows 上,R 包是动态链接库。它们可以通过使用完整文件名(包括 dll 扩展名)的dyn.load()
函数动态加载,或者,更典型的是,对于已安装(注册)的包,使用library()
命令。其中,RStudio 提供了一个方便的 IDE 来管理软件包的安装和加载。
为了与包进行通信,R 语言和环境提供了一个低级的 C 风格的 API(应用程序编程接口)。一旦加载了包,用户就可以调用包中的函数,传递参数,并返回结果。这意味着一旦我们将StatsR.dll构建成一个包,我们就可以加载它并执行下面的命令,例如:
> .Call("_StatsR_get_descriptive_statistics", c(0,1,2,3,4,5,6,7,8,9), c("StdErr"))
StdErr 0.9574271
这调用了带有两个作为集合传递的参数的get_descriptive_statistics
函数:数据和一个键“StdErr
”。结果按预期返回。我们在调用_StatsR_get_descriptive_statistics
时使用的实际函数名是 C 风格的导出函数名。我们可以通过使用类似于Depends.exe的工具检查StatsR.dll来得到这个。
然而,这个 API 非常低级,对于扩展开发来说并不理想。我们在这里的目的是从底层 C++ 统计库中公开(有限)数量的函数。使用 C 风格的 API 方法,我们需要将所有函数声明为类型extern "C" SEXP
。这是一个指向SEXPREC
或简单表达式记录的指针,r 使用的不透明指针类型。此外,参数必须被类型化为指向 S 表达式对象的指针(SEXP
)。使用 C 风格的 API 确实允许我们在 C++ 和 R 之间交换数据和对象,但是对于更复杂的 C++ 开发来说,这不是一个实际的建议。
Rcpp 框架解决了这个问题。Rcpp 层位于.Call()
API 之上,保护 C++ 开发人员不需要使用底层的SEXP
类型。Rcpp 提供了一个接口,可以自动将标准 C++ 翻译成对低级 API 的调用。从开发的角度来看,Rcpp 允许我们对包装组件使用标准 C++。
用 Rcpp 构建一个包
安装 Rcpp
Rcpp 包可以通过运行 R 命令:install.packages("Rcpp")
来安装。或者,从 RStudio 菜单中,我们可以使用工具➤安装包...命令。一旦完成,我们就可以构建 Rcpp 包了。在 RStudio 中,打开 StatsR 项目:文件➤打开项目...。 StatsR。Rproj 文件位于 SoftwareInteroperability 目录下的 StatsR 目录下。
项目文件
RStudio IDE 提供了直接创建 Rcpp 项目的工具。StatsR 是使用文件➤新建项目创建的,在新建项目向导中,选择新建目录,然后选择“R Package using Rcpp”和一个目录名。这样,样板文件就生成了。我们可以从头开始生成所需的包文件,也可以使用命令Rcpp.package.skeleton
来生成项目文件。在我们的例子中,Rcpp 项目模板在 StatsR 项目目录下的几个子目录中生成文件。下面列出了这些文件,并对每个文件进行了简要说明:
-
状态先生。项目
这是 RStudio 项目文件。
-
描述
这个文件包含关于这个包的描述性信息(包名、类型、版本、日期、作者等等)。它还包含关于包依赖关系的元数据。更多关于包元数据的详细信息,请参见附加资源一节。
-
命名空间
这个文件包含三个指令。首先,
useDynLib(...)
确保作为这个包的一部分的动态库被加载和注册。接下来,importFrom(...)
指令从其他包中导入变量(除了 baseR,它总是被导入)。在这种情况下,我们从 Rcpp 和 evalCpp 包中导入变量。最后一个指令exportPattern(...)
声明了哪些标识符应该从这个包的名称空间中全局可见。默认情况下,导出所有以字母开头的标识符。这是在正则表达式中定义的。 -
\man\StatsR-package。研发
这是一个 R markdown 模板文件,用于描述软件包。您可以在 RStudio 中进行编辑。按下预览按钮会在帮助窗口中显示格式化的内容。
-
\ R \ RCP 导出。R
这个文件包含 Rcpp 生成的 R 语言函数调用。
-
\ src \ rcppeexports . CPP
这个文件包含 Rcpp 生成的 C++ 函数。
-
\src\Makevars.win
该文件包含编译器/链接器的配置选项。
-
\ src \ statsr CPP
这是我们将在本章中使用的主要文件,包含样板代码。
编辑生成文件
在包装方面,到目前为止,我们一直在从两边向内努力。一方面,我们将统计函数库重建为 libStatsLib.a 。另一方面,我们使用 Rcpp 创建了一个 StatsR 项目。现在,我们需要将 C++ 统计函数库链接到 Rcpp 项目中。为此,我们需要更新 Makevars.win 。这个文件可以在 \src 目录下找到。 Makevars.win 是这个项目的 Windows makefile。它覆盖了默认的构建配置文件 Makeconf 。作为参考,这个文件可以通过运行file.path(R.home("etc"), "
Makeconf
")
命令找到。它包含了使用 gcc 工具链进行编译和链接的所有设置,因此应该小心处理。对于这个项目,配置要简单得多。我们只使用一面旗帜:
PKG_LIBS
:该标志用于链接额外的库(如 libStatsLib.a )。
根据构建目标,另外两个感兴趣的标志是
-
PKG_CXXFLAGS
:该标志可用于设置额外的调试或发布选项。对于调试,我们用调试信息构建 gdb (-ggdb
)、零优化级别(-o0
)和警告级别(-Wall
)。对于发布版本,我们删除了这些设置。 -
PKG_CPPFLAGS
:这些与预处理器标志有关,可以用来用-I
设置附加的包含目录。
附加资源部分提供了对标志及其用法的更详细描述的链接。回到 Makevars.win ,我们在 makefile 的底部添加了以下几行:
## Directory where the static library is output
PKG_LIBS=<your path>/SoftwareInteroperability/StatsLibCB/bin/Release/libStatsLib.a
这将告诉链接器链接到发布版本的 libStatsLib 库。保存您的更改。
样板代码
还是在 \src 目录下,打开文件 StatsR.cpp 。这里有一些有用的生成的样板代码,我们将使用它们来检查构建过程。清单 5-1 显示了代码。
-
Listing 5-1Boilerplate C++ function in the StatsR package
在这个文件中,我们定义了一个名为library_version
的 C++ 函数,它返回一个硬编码的字符串。在这个小例子中,有几个特性值得强调。
首先,在文件的顶部,我们包含了 Rcpp.h 。这是主 Rcpp 集管。你可以在你的 R 发行版(例如, D:\R\R-4.0.3\library )中的 \library\Rcpp 下找到它,以及其余的源代码。Rcpp 是一个相当大的包(大约 300 多个文件),并且有很多值得探索的工具。文档目录( Rcpp\doc )包含了大量有用的 bitesize 参考文档,值得参考。在这本书的两章中,我们仅仅触及了 R 的皮毛。
其次,值得注意的是属性
// [[Rcpp::export]].
这表明我们想让这个 C++ 函数对 r 可用。函数本身相当简单。它构造一个String
对象并将其返回给调用者。
RStudio IDE 非常适合编写和开发 R 脚本。然而,对于 C++ 开发来说,它就没那么有用了,尤其是当它能够通读源代码或者访问类型定义的时候(就像前面例子中的String
)。虽然不是绝对重要的,但是能够右键单击一个符号并跳转到定义(如果可能的话)是很好的。这也使得浏览源代码和调查任何与类型转换相关的编译错误变得更加容易。
考虑到这一点,下面是使用 Visual Studio 代码实现这一点的一种快速且非侵入性的解决方法。在 Visual Studio 代码中打开 StatsR 目录(文件➤打开文件夹…),然后打开 StatsR.cpp 文件。为此,您需要安装 VSCode C++ 插件(“Visual Studio 代码的 C/C++”)。编辑插件配置文件( <你的路径>\ software inter operability \ StatsR \vs code \ c _ CPP _ properties . JSON)来查找 Rcpp 位置和根 include 目录中的源代码。将清单 5-2 中的"configurations"
部分添加到 c_cpp_properties.json 属性文件中。
-
Listing 5-2Adding include paths to the c_cpp_properties.json file in VSCode
这样,你可以右击符号(或按 F12)并跳转到如图 5-7 所示的定义。
图 5-7
使用 Visual Studio 代码导航 Rcpp 源文件
看图 5-7 ,原来String
类封装了一个CHARSXP
——一个char
类型的 S 表达式指针(粗略来说)。
建筑统计
回到函数library_version
:我们将使用这个简单的函数来测试构建的端到端。我们应该能够从清单 5-3 中的最小 R 脚本中调用这个函数。
library(StatsR) # Load the library
res = StatsR::library_version() # Retrieve the library version
res # Display it
Listing 5-3A simple test R script
单击“构建➤清理并重建”(或从“构建”窗格的“构建”菜单中)。有时,当前的 R 会话是活动的,例如,如果您在打开一个项目时重新加载了环境。这将导致清理和重建显示一条消息,表明库正在使用中,如下所示:
ERROR: cannot remove earlier installation, is it in use?
* removing 'D:/R/R-4.0.3/library/StatsR'
* restoring previous 'D:/R/R-4.0.3/library/StatsR'
...
Exited with status 1.
如果发生这种情况,只需从主菜单中选择会话➤重启 r,然后像以前一样继续操作。输出应该如清单 5-4 所示。
-
Listing 5-4Clean and Rebuild output
清单 5-4 详细展示了构建过程中采取的步骤。正如所料,“清理和重建”过程有些复杂。第一阶段是对Rcpp::compileAttributes()
的调用。这将检查 \src 目录中的 C++ 函数,并查找表单// [[Rcpp::export]]
的属性。当它找到一个时,它生成 C++ 和将函数公开给 R 所需的 R 代码。这些函数包装器在src/RCP exports . CPP和R/RCP exports 中生成。R (注意不同的文件扩展名和位置)。更具体地说,Rcpp 使用 export 属性生成一个函数包装器,它将 R 函数library_version
映射到 C 风格的函数调用。这是 R 调用(在RCP exports 中找到。R )。清单 5-5 显示了 R 函数。
-
Listing 5-5The R function stub generated from the library_version C++ code
你可以看到这使用了我们之前描述过的底层.Call()
接口。相应的 C++ 函数也在RCP exports . CPP中生成。这显示在清单 5-6 中。
-
Listing 5-6Low-level C++ code generated by Rcpp
第一行(注释之后)是 C++ 函数的函数签名。接下来是 C 风格的 API 声明。在函数内部,Rcpp 已经生成了调用函数并返回结果的代码。在下一章中,我们将对这里生成的 Rcpp 代码有更多的介绍。
除了生成的 C++ 函数包装器,RCP exports . CPP还包含模块定义。这是从函数名到导出函数地址的映射。它还包含关于参数数量的信息。您应该永远不需要直接使用这些文件。两个文件(src/RCP exports . CPP和R/RCP exports。R )被标记为只读。不建议手动修改这些文件。
总结一下目前发生的事情:我们已经写了一个 C++ 函数library_version
(事实上这是样板代码,但是过程是一样的);Rcpp 已经生成了一个 R 函数和底层包装器代码,该代码将 Rcpp 类型翻译成 R .Call()
API 理解的底层类型。
在文件生成之后,构建过程会构建一个 DLL 并使它对 r 可用。您可以通过查看您的 R 发行版中的 \library 来确认这一点。在我们的例子中,它位于D:\ R \ R-4 . 0 . 3 \ library \ StatsR下。最后,构建过程会生成一些文档。如果需要,可以配置构建过程使用 roxygen2。在这种情况下,我们坚持使用默认的 R markdown 文档。这用于在包位置生成文档的 html 版本(D:/R/R-4 . 0 . 3/library/StatsR/html/StatsR-package . html)。
如果一切按计划进行,我们现在应该在项目目录中的 \src 下找到一个StatsR.dll。并且应该将其加载到 RStudio 环境中。您可以通过执行清单 5-7 中的命令来确认。
-
Listing 5-7Obtaining a list of the loaded DLLs
StatsR 包出现在清单 5-7 中加载的 dll 列表的底部。根据当前加载的内容,您的输出会有所不同。我们可以通过执行以下命令来检查 version 函数是否按预期工作:
> library_version()
输出应该是:[1] "StatsR, version 1.0"
。
此外,我们可以检查包中可用的功能,如下所示:
> library(pkgload)
> StatsFunctions = names(pkg_env("StatsR"))
> as.data.frame(StatsFunctions)
StatsFunctions
1 t_test_two_sample
2 get_descriptive_statistics
3 t_test_one_sample
4 t_test_summary_data
5 library_version
6 linear_regression
完成这些之后,我们就有了一个完全可用的 Rcpp 包,它为我们的 C++ 统计函数库提供了一个包装器。
摘要
我们在这一章中涉及了相当多的内容。我们已经使用 gcc 编译器/链接器(重新)构建了统计函数库。我们还构建了包装组件,StatsR.dll。这很方便,因为它允许我们不加修改地重用源代码,同时将包装组件(【StatsR.dll】)从底层 C++ 代码中分离出来。
本章主要关注于建立构建使用 C++ 功能的 R 包所需的基础设施。应该强调的是,这种安排只是组织 R 包开发和构建过程的许多可能方式中的一种。随着代码块作为我们的 C++ 开发 IDE 开放,我们现在可以开发 C++ 代码,例如,我们可以编译并构建到静态库( libStatsLib.a )中。然后,在 RStudio 中,我们可以使用我们的 Rcpp 项目( StatsR )来公开 C++ 函数。我们可以将其构建到 R 包中,并在 R 会话中立即使用该功能。我们现在有了端到端 C++ 和 R 开发的基础设施。有了这个基础设施,我们现在可以考虑使用 Rcpp 了。在下一章中,我们将更详细地查看我们在包装组件中使用的 Rcpp 框架,以及统计函数是如何通过 Rcpp 暴露给 R 的。
额外资源
以下链接提供了本章所涵盖主题的更多信息:
-
关于在 Visual Studio 代码下使用 GCC 和 MinGW 的详细信息可从
https://code.visualstudio.com/docs/cpp/config-mingw
获得。除了配置 Visual Studio 代码以使用 GCC C++ 编译器(g++)和 mingw-w64 中的 GDB 调试器之外,本教程还演示了编译和调试。 -
代码块的完整文档可在
www.codeblocks.org/user-manual/
获得。 -
关于包元数据的全部细节,下面的链接非常有用:
https://r-pkgs.org/description.html
,尤其是第八章。 -
如果你不熟悉 GCC 编译器的设置和选项,下面的链接提供了一个有用的列表:
https://caiorss.github.io/C-Cpp-Notes/compiler-flags-options.html
。 -
有关编译器/链接器开关的详细信息,请参见使用 Makevars:
https://cran.r-project.org/doc/manuals/r-devel/R-exts.html#Using-Makevars
一节。这篇文档也给出了很多关于编写 R 扩展的有用信息。下面 Stackoverflow 的帖子提供了一个有用的总结:https://stackoverflow.com/questions/43597632/understanding-the-contents-of-the-makevars-file-in-r-macros-variables-r-ma/43599233#43599233
。
练习
接下来的练习主要处理向 C++ 代码库添加代码和在库中构建这些更改的效果,我们可以使用这些库来构建 R 包。这些练习与设置 R/RStudio 中使用的基础设施有关。
1)重新构建 libStatsLib.a ,准备在 R/RStudio 中使用。这里的目的是重新编译静态库中的代码,并确保我们可以将它链接到 StatsR 项目。
接下来的步骤是
-
在代码块中打开 StatsLibCB 项目。
TimeSeries
类已经作为项目的一部分,所以不需要做任何事情。展开 Sources 节点,确认 TimeSeries.cpp 存在。对头文件进行同样的操作。如果你已经在statisticaltests . h/statisticaltests . CPP中添加了一个ZTest
类,那么它们可以立即被构建。另一方面,如果您已经在单独的文件中添加了
ZTest
类,那么您将需要将这些文件添加到 StatsLibCB 项目中。为此,请选择“项目➤属性”、“构建目标”选项卡,然后添加它们。 -
构建调试/发布版本。他们应该在没有警告或错误的情况下构建。检查文件是否正在编译/链接。
-
打开 RStudio。选择 Build ➤ Clean and Rebuild,检查构建是否(仍然)正常工作,没有警告或错误。确认 StatsR 包加载并工作。
六、使用 Rcpp 公开函数
介绍
在前一章中,我们使用 Rcpp 构建了一个 R 包。此外,使用代码块,我们建立了开发和构建符合 ABI 标准的统计函数库( libStatsLib.a )的基础设施,我们将它链接到我们的 R 包(StatsR.dll)中。目前,我们只使用了一个函数,library_version
(在 StatsR.cpp 中定义)。我们用这个来说明构建过程,并测试 R 和 C++ 之间的通信。
在这一章中,我们将详细介绍如何公开底层统计库的功能。我们首先看描述性统计和线性回归函数。然后我们在统计测试类的上下文中检查 RcppModules。本章的最后一部分着眼于在其他 R 包中使用这个组件。我们涵盖了测试、测量性能和调试。这一章以一个闪亮的小应用程序演示结束。
转换层
在 C++/CLI 包装器中(第三章),我们花了一些时间开发了一个显式转换层,在那里我们放置了在托管环境和本机 C++ 类型之间进行转换的函数。Rcpp 采取的方法意味着我们不再需要这样做。除了标准的 C++ 类型之外,我们还利用了 Rcpp C++ 名称空间中定义的类型,并且让 Rcpp 生成底层代码,允许 R 和 C++ 之间的通信。这个接口确保了我们的底层统计库是独立的,并且独立于 Rcpp。
正如前一章所指出的,Rcpp 名称空间非常广泛。它包含许多函数和对象,这些函数和对象将我们与 r 提供的基本底层 C 接口隔离开来。我们只使用了一小部分功能,特别集中在Rcpp::NumericVector
、Rcpp::CharacterVector
、Rcpp::List,
和Rcpp::DataFrame
上。
代码组织
StatsR 项目中的 C++ 代码组织在项目的 \src 目录下。这是我们定位项目的 C++ 可编译单元的地方。在此目录下,我们已经看到了以下内容:
-
StatsR.cpp :包含一个样板 Rcpp C++ 函数
-
RCP exports . CPP:包含生成的 C++ 函数
-
Makevars.win :包含 Windows 构建配置设置
除了前面的列表,我们还有以下三个文件,每个文件对应一个我们想要公开的功能区域:
-
descriptivestatistics . CPP
-
线性回归。cpp
-
StatisticalTests.cpp
这是一种组织功能的便捷方式,我们将依次处理每一个功能。
描述统计学
《守则》
清单 6-1 展示了 C++ 包装函数get_descriptive_statistics
的代码。
-
Listing 6-1C++ code for the DescriptiveStatistics wrapper function
查看清单 6-1 ,有几点值得强调:
-
包含文件:在这里,我们
#include
主 Rcpp 头文件,后面跟着标准库 includes。接下来是"Stats.h"
的#include
。 -
注释块:这里,我们记录了函数参数的名称和类型。我们还使用
@export
符号,通过将 R 包装器函数添加到名称空间中,使其对这个包之外的其他 R 函数可用。不要将其与后面的Rcpp::export
属性混淆。 -
属性:我们标记函数
[[Rcpp::export]]
。这表明我们想让 r 使用这个 C++ 函数。我们已经在上一章看到了一个使用library_version
函数的例子。 -
包装函数:最后,代码本身——R 函数被调用
get_descriptive_statistics
。第一个参数是一个NumericVector
。第二个参数是可选的CharacterVector
。如果没有提供参数,这是默认的。默认参数是使用静态create
函数指定的。这允许我们保留与原生 C++ 函数相同的调用语义。也就是说,我们可以用一个或两个参数来调用它。get_descriptive_statistics
函数返回一个std::unordered_map<std::string, double>
,底层的 C++ 函数也是如此。
清单 6-1 中的get_descriptive_statistics
函数内的代码很简单。我们使用 Rcpp 函数as<T>(...)
将传入参数NumericVector vec
( typedef
'd as Vector<REALSXP>
)从SEXP
(指向 S 表达式对象的指针)转换为std::vector<double>
。类似地,我们使用Rcpp::as<T>
将CharacterVector
键转换为string
s 的向量。我们将参数传递给底层 C++ 库函数GetDescriptiveStatistics
并检索结果。然后使用本地 STL 类型将结果传递回 R。在引擎盖下,结果被包装起来,如下所述。
从前面的描述中应该可以清楚地看到,Rcpp 允许我们编写 C++ 代码,而完全不会造成干扰。此外,Rcpp 促进了开发过程。我们来举一个具体的例子。如果我们希望添加函数来显示底层的单个统计数据,例如,ExcessKurtosis
,这是一个简单的变化。我们需要包含描述性统计头文件:
#include "../../Common/include/DescriptiveStatistics.h"
接下来,我们创建一个新函数,并将其标记为导出,如以下代码所示:
//' Compute excess kurtosis
//'
//' @param data A vector of doubles.
//' @export
// [[Rcpp::export]]
double excess_kurtosis(Rcpp::NumericVector data) {
std::vector<double> _data = Rcpp::as<std::vector<double> >(data);
double result = Stats::DescriptiveStatistics::ExcessKurtosis(_data);
return result;
}
这个函数接受一个NumericVector
并返回一个double
。该函数使用Rcpp::as<T>
将NumericVector
转换为std::vector<double>
,然后调用底层库并返回结果。您可能想尝试添加它,重新构建包,并以交互方式测试该函数,如下所示:
> StatsR::excess_kurtosis(c(0,1,2,3,4,5,6,7,8,9))
[1] -1.224242
正如我们已经看到的,当我们调用“清理和重建”时,Rcpp 框架更新生成的src \ RCP exports . CPP文件。查看文件中生成的实际导出函数(而不是编辑它)是有指导意义的。如清单 6-2 所示。
-
Listing 6-2Rcpp generated code for the get_descriptive_statistics function
查看清单 6-2 中生成的代码,我们可以看到这与我们编写的 C++ 函数有多么相似。函数名由包名和 C++ 名合成;因此,它被称为“??”。这是用RcppExport
宏声明的。这将函数声明为extern "C"
。除此之外,我们编写的包装器 C++ 代码和 Rcpp 生成的 C++ 代码之间的主要区别在于使用的类型。在不纠缠于细节的情况下,Rcpp 对传入类型使用SEXP
(S 表达式指针)。它使用一个RObject
类型作为输出类型。这些基本上都是指针类型。Rcpp::wrap
使用wrap_dispatch
的一种形式,用返回对象的副本创建一个新指针,例如:
template <typename T> inline SEXP wrap_dispatch(const T& object, ::Rcpp::traits::wrap_type_module_object_tag) {
return Rcpp::internal::make_new_object<T>(new T(object));
}
同时,它将类型转换为一个RObject
,并将RObject
指针赋给rcpp_result_gen
,然后返回给 r,从GetDescriptiveStatistics
返回的std::unordered_map
的副本被销毁,而RObject
包含一个副本。从这个描述中可以清楚地看到,在稍微高一点的层次上,Rcpp::wrap
围绕从我们的本地 C++ 代码返回的(指向)对象提供了 RAII(资源获取是初始化)。也就是说Rcpp::wrap
提供了生存期管理,这大大简化了 C++ 包装器代码。
您可能想知道这在 R 会话中实际上是如何呈现的。从 R 的角度来看,std::unordered_map<std::string, double>
作为数值类返回,如清单 6-3 中的脚本所示。
# StatsR
stats <- StatsR::get_descriptive_statistics(data)
> class(stats)
[1] "numeric"
Listing 6-3Retrieving the R class from a C++ wrapper function
我们返回的数字向量使用了Named
类。Named
类是一个助手类,用于设置键/值对的键端。结果是调用get_descriptive_statistics
返回一个带标签的数字向量,如清单 6-4 所示。
> stats <- StatsR::get_descriptive_statistics(c(0,1,2,3,4,5,6,7,8,9))
> stats
Variance.S Sum StdErr StdDev.P Skew.P Mean Count Skew.S Maximum Variance.P StdDev.S
9.1666667 45.0000000 0.9574271 2.8722813 0.0000000 4.5000000 10.0000000 0.0000000 9.0000000 8.2500000 3.0276504
Range Kurtosis Minimum Median
9.0000000 -1.2000000 0.0000000 4.5000000
Listing 6-4Labelled output from the get_descriptive_statistics function
我们可以转置输出,使指定的列变成行,只需将返回的NumericVector
强制转换成数据帧,如下所示:
> stats <- as.data.frame(stats)
> stats
stats
Variance.S 9.1666667
Sum 45.0000000
...
异常处理
回到在RCP exports . CPP中生成的代码,有一个细节我们跳过了:宏BEGIN_RCPP/END_RCPP
。这些宏定义了try{...}catch{...}
块来处理 C++ 代码可能抛出的异常。异常处理逻辑相当复杂。如果你有兴趣,宏的定义在\ Rcpp \ include \ Rcpp \ macros \ macros . h中。如果底层 C++ 函数抛出一个std::exception
,它将被捕获并被适当地转换。清单 6-5 显示了一个例子。
# StatsR
> stats <- StatsR::get_descriptive_statistics(c(1,2))
Error in StatsR::get_descriptive_statistics(c(1, 2)) : Insufficient data to perform the operation.
Listing 6-5An example of exception handling
从清单 6-5 中,我们可以看到,如果我们传递给底层GetDescriptiveStatistics
函数的数据点太少,异常就会以一种信息丰富的方式被报告。总结到目前为止我们所看到的,很明显 Rcpp 框架允许我们编写干净的 C++ 代码,同时注意与 R 和 C++ 之间的转换相关的许多细节。
行使功能
在进行清理和重建之后,我们可以使用get_descriptive_statistics
函数,并将结果与等价的基 R 函数进行比较。剧本描述了统计主义。R 说明了这样做的一种方法。首先,我们加载一些额外的包:tidyverse
和formattable
,等等。然后,该脚本生成 1000 个正态分布的随机样本。接下来,我们创建两组数据,一组来自 StatsR ,另一组使用等价的 Base R 函数。我们创建一个列来比较结果,并将三列( StatsR 、 BaseR、和 Results )添加到数据帧中。然后将数据框格式化为表格。根据结果列中的TRUE/FALSE
值,行的颜色会发生变化,这使我们可以很容易地发现结果中的差异。如图 6-1 所示。
图 6-1
统计数据比较:StatsR 与 BaseR
从图 6-1 的表格中,我们可以立即看到两个库产生的值没有数字差异。
线性回归
《守则》
展示简单的一元线性回归的 C++ 代码遵循与描述性统计相同的模式。这显示在清单 6-6 中。
-
Listing 6-6Wrapper function for LinearRegression
在#include
之后,函数本身被声明为接受两个NumericVector
并像之前一样使用std::unordered_map<std::string, double>
返回结果。和以前一样,我们使用Rcpp::as<T>
将传入的向量复制为 STL 类型,并依靠隐式的wrap
将结果转换为名称/值对包。正如上一节所讨论的,我们将异常处理留给 Rcpp 框架生成的代码。
行使功能
我们想要测试这个包装器函数,例如,通过对一些房价数据建模并预测新的价格。脚本 LinearRegression。清单 6-7 中所示的 R 演示了一种实现方法。
-
Listing 6-7A simple linear model for house price prediction
清单 6-7 中的脚本从加载StatsR
库和ggplot2
开始。我们定义一个简单的predict
函数,它将使用线性回归的结果。接下来,我们加载数据。这与我们在第四章中使用的数据相同(在data modeling . cs中)。接下来,我们绘制数据并添加回归线。如图 6-2 所示。
图 6-2
房价与面积的散点图
我们调用包装函数StatsR::linear_regression
来获得模型结果,并使用系数来预测新的值。最后,我们将结果与 r 中等效的(但更强大的)lm
函数进行比较。我们可以看到截距(b0
)和斜率(b1
)是相同的。
使用数据框架
从 R 用户的角度来看,linear_regression
函数可以通过用DataFrame
调用来改进。我们可以重写linear_regression
函数来做到这一点,如清单 6-8 所示。
-
Listing 6-8Passing a DataFrame to the linear_regression function
我们可以从清单 6-8 中看到,这个函数与前一个函数的唯一区别是我们传入了一个参数,一个Rcpp::DataFrame
。我们假设有标签为"x"
和"y"
的列。如果所需的列名不存在,则会生成一个错误:
("Error in StatsR::linear_regression(data) : Index out of bounds: [index="x"]."
)。
我们像以前一样将列提取为std::vector<double>
类型,然后传递给 C++ LinearRegression
函数。结果像以前一样返回。调用该函数现在看起来像这样:
> data <- data.frame("x" = c(1.1, 1.9, 2.8, 3.4), "y" = c(1.2, 2.3, 3.0, 3.7))
> results <- StatsR::linear_regression(data)
> results
b1 b0 SS_xy x-mean SS_xx y-mean
1.0490196 0.1372549 3.2100000 2.3000000 3.0600000 2.5500000
这种方法的唯一警告是编译器不允许两个linear_regression
函数同时存在。编译器的错误是
"conflicting declaration of C function 'SEXPREC* _StatsR_linear_regression(SEXP)' "
。
似乎无法区分单参数情况和双参数情况。我们可以坚持使用一个单一的函数,或者重命名其中一个函数。这里重要的一点是,在包装层中,您可以选择如何转换类型并将其呈现给用户。
统计测试
函数与类
公开统计测试功能的代码位于 StatisticalTests.cpp 中。我们最初采用与之前在 StatsExcel 组件中相同的方法来包装功能。也就是说,我们将一个 C++ 类包装在一个过程接口中。清单 6-9 显示了部分代码。
-
Listing 6-9Wrapper function to perform a t-test from summary data
清单 6-9 中的代码显示了从汇总输入数据中执行 t-test 的函数。包装函数将四个 doubles 作为参数(double mu0, double mean, double sd, double n
),并将结果作为一组键/值对返回。在代码中,我们需要构造一个与汇总数据 t-test 对应的Stats::TTest
对象。我们使用函数参数作为构造函数的参数。在一个样本和两个样本的情况下,我们传递一个或两个NumericVector
,它们根据需要被转换成一个std::vector<double>
。这些是我们之前看到的相同类型的转换。调用test.Perform
后,我们获得结果集。我们可以明确地检查Perform
是返回true
还是false
。但是,如果抛出异常,它将由 Rcpp 生成的代码处理。
Rcpp 模块
正如我们所看到的,通过 Rcpp 向 R 公开现有的 C++ 函数和类非常简单。到目前为止,我们采用的方法是编写一个包装函数。这个接口函数负责将输入对象转换为适当的类型,调用底层 C++ 函数,或者构造一个实例(如果它是一个类),然后将结果转换回适合 r 的类型。我们已经看到了许多这两种用法的示例:用包装函数公开函数和类。
然而,在某些情况下,可能希望能够将类直接公开给 r。例如,如果底层 C++ 类具有重要的构造逻辑。我们宁愿公开一个可以由 R 管理的类对象,而不是像我们处理 t-test 包装函数那样,在每次函数调用时产生构造类实例的成本。更一般地说,直接公开类允许我们保留底层的对象语义。Rcpp 框架提供了一种通过 Rcpp 模块公开 C++ 类的机制。Rcpp 模块还允许将功能和类分组到一个一致的模块单元中。
为了创建一个模块,我们使用了RCPP_MODULE
宏。在宏内部,我们声明我们公开的类的构造函数、方法和属性。清单 6-10 展示了如何将TTest
类与模块声明一起暴露给 R。
-
Listing 6-10Exposing the TTest class via the RCPP_MODULE macro
清单 6-10 中的代码在\ src \ statisticaltests . CPP中。这段代码有两个部分。第一部分声明了一个 C++ TTest
包装类。这个类包装了一个本地的Stats::TTest
成员。C++ 包装类用于执行类型之间所需的转换。汇总数据和单样本 t-tests 的构造函数采用与过程包装器中相同的 Rcpp 参数,并执行我们之前看到的相同转换。双样本 t-test 使用了一个包含两个数字向量的Rcpp::List
对象,这两个向量分别标为“x1
和“x2
”。方法Perform
和Results
被简单地转发给底层的本地Stats::TTest
实例。设计模式类似于pimpl
(指向实现的指针)习惯用法或门面或适配器模式。
代码的第二部分声明了RCPP_MODULE
宏。我们将类名定义为“ StatsTests ”。这将被 R 用来识别模块。在模块中,使用关键字class_
公开一个类。尾部下划线是必需的,因为我们不能使用 C++ 语言关键字class
。这里,class_<T>
由 C++ class
或struct
模板化,它们将暴露给 R,在本例中,R 是我们的包装类的名称。传入到class_<TTest>
构造函数中的字符串 "TTest" 是我们从 r 调用该类时将使用的名称。接下来,我们从构造函数、方法和字段(此处未示出)的角度来描述该类。我们可以看到,在这种情况下,我们有三个对应于汇总数据 t-test 的构造函数,以及单样本和双样本 t-test。模板参数是各自底层构造函数的参数。使用Rcpp::List
而不是两个Rcpp::NumericVector
参数是打包输入参数的一种便捷方式。对于RCPP_MODULE
构造函数方法无法区分以下构造函数的问题,它也提供了一个简单的解决方法:
.constructor<double, Rcpp::NumericVector>
.constructor<Rcpp::NumericVector, Rcpp::NumericVector>
除了构造函数,在清单 6-10 中,我们可以看到我们有两个方法。method
函数采用函数名,后跟包装函数的地址,再跟一个帮助字符串。总的来说,我们向 Rcpp 提供了类的声明性描述。我们还提供文档字符串。清单 6-11 展示了一个如何使用TTest
类的例子。
moduleStatsTests <- Module("StatsTests", PACKAGE="StatsR")
ttest0 <- new(moduleStatsTests$TTest, 5, 9.261460, 0.2278881e-01, 195)
if(ttest0$Perform()) {
print(ttest0$Results())
} else {
print("T-test from summary data failed.")
}
Listing 6-11Using the TTest class in an R script
在清单 6-11 中,我们通过调用名为"StatsTests"
的Module
函数来创建一个模块对象。模块内的实体可以通过$
符号访问。注意,在我们有限的例子中,我们只在 Rcpp 模块中放置了一个实体。然而,没有理由不包含其他类和相关的功能。在 R 中,我们使用对象名后跟参数的new
将TTest
类实例化为ttest0
。然后,我们可以使用实例ttest0
来执行测试,并打印结果或错误消息。
总的来说,RcppModules
提供了一种方便的方法来分组功能和公开 C++ 类。因此,我们可以选择编写包装函数或包装类,无论哪个最适合我们的目的。这是对RcppModules
的简单介绍。这种方法有许多细节我们没有在这里介绍。
测试
既然我们已经展示了底层统计库的功能,那么测试一切是否如预期那样工作是很有用的。对于单元测试,我们使用“ testthat ”库( https://testthat.r-lib.org/
)。测试组织在主项目下的 \tests\testthat 目录中。测试。r 下的脚本\tests 调用下的单元测试\testthat 下的单元测试。有三个测试文件对应于三个功能领域:
-
检验 _ 描述 _ 统计。R
-
检验 _ 线性 _ 回归。R
-
测试 _ 统计 _ 测试。R
测试遵循我们以前使用过的相同的排列-动作-断言形式。在描述性统计和线性回归测试的情况下,我们根据基数 R 函数检查结果。清单 6-12 显示了一个线性回归测试的例子。
-
Listing 6-12The LinearRegression test
清单 6-12 中的LinearRegression
测试创建x
和y
值,并将它们放入数据帧中。然后我们调用 R 函数lm
,后面跟着我们的LinearRegression
函数。最后,我们比较截距系数和斜率系数。
对于统计假设测试,我们选择测试硬编码的期望值(清单 6-13 )。
-
Listing 6-13Testing the summary t-test from data
在清单 6-13 中,我们只测试包装函数,因为它比类稍微容易调用。
所有的测试都可以通过打开test 来运行。R 脚本并点击Source
按钮。如图 6-3 所示。
图 6-3
运行测试工具
图 6-3 中测试运行的输出表明所有测试(其中 34 项)都通过了。没有跳过失败、警告或测试。它还输出测试持续时间。
衡量绩效
使用 C++ 编写底层代码的原因之一是,与只使用 r 相比,它有可能提高性能。清单 6-14 显示了基准。R
-
Listing 6-14shows Benchmark.R
清单 6-14 中的基准脚本比较了 C++ linear_regression
函数和 R 的lm
函数的性能。这种比较有些人为。r 的lm
函数远比我们简单的线性回归函数灵活。该比较仅用于说明目的。该脚本加载了许多库,包括rbenchmark
库。这对于微基准功能非常有用。我们使用众所周知的 R 数据集mtcars
来执行mpg
对weight
的回归。像往常一样,我们预先绘制数据,并使用密度图检查分布。我们将感兴趣的两个函数包装在虚拟函数中,这样bench:
:mark
就不会抱怨结果集不同。然后我们用这两个函数调用bench::mark(...)
。我们将结果输出到控制台。
total_time
1 StatsR(mtcars$wt, mtcars$mpg) 178ms
2 R_LM(mtcars) 491ms
实际结果比前面显示的要详细得多。然而,我们总结了total_time
来说明这种方法。我们可以看到, StatsR 函数所用的total_time
为 178 毫秒,而 R_LM 函数所用的为 491 毫秒。我们还绘制了输出,如图 6-4 所示。
图 6-4
StatsR 和 R lm 函数的基准比较
时间上的差异并不奇怪,因为lm
函数比我们有限的LinearRegression
函数做得更多。
排除故障
RStudio 支持在 IDE 中调试 R 函数。只需在适当的位置设置断点,然后 Source 该文件。然后,我们可以一行一行地遍历 R 代码,交互地检查变量等等。不幸的是,调试包中的 C++ 代码更加困难,信息量也更少。使用 gdb 可以做到这一点。然而,为此我们需要使用 Rgui 而不是 RStudio 作为主机环境。对调试 R 的全面讨论超出了本章的范围。但是,如果需要,连接到 Rgui 进程并进入调试器的过程如下:
-
导航到带有源代码的目录(\ software inter operability \ StatsR \ src)。
-
用 Rgui 作为参数启动 gdb,如下:
gdb D:/R/R-4.0.3/bin/x64/Rgui.exe
。
图 6-5 显示了这些命令。
图 6-5
典型的 gdb 会话
请注意,我们将 gdb 会话与 Rgui 会话交错进行。启动 gdb 后,输入run
。这将运行 Rgui。见图 6-5 。然后在 Rgui 中,运行devtools::load_all()
。这将重建StatsR.dll如果必要的话,并将安装和加载软件包。接下来,在 Rgui 中,选择杂项➤中断到调试器以返回到 gdb 会话。在 gdb 中,设置你想要的断点。例如,我们可以在get_descriptive_statistics
上设置一个断点。使用命令:
break get_descriptive_statistics
然后按c
将控制返回到 Rgui 并继续。在 Rgui 中,执行
> get_descriptive_statistics(c(1,2,3,4,5,6,7,8), c("Mean"))
这将在调用位置中断调试器。从这里我们可以单步执行函数调用(命令n
)。然而,来自单个函数调用的信息非常有限,这使得调试没有发挥应有的作用。
分发浏览器
正如在第四章中指出的,当开发包装组件时,我们不仅关心函数(和类)是否正确工作,还关心组件作为一个整体如何互操作。考虑到这一点,StatsR 项目包含一个名为 Distribution Explorer 的闪亮的小应用程序。这是基于来自闪亮画廊( https://shiny.rstudio.com/gallery/
)的一个现有例子,并适应使用 StatsR 功能。用户界面如图 6-6 所示。
图 6-6
StatsR 闪亮应用
Distribution Explorer 从左侧面板中选择的分布中生成一定数量(可配置)的随机观察值。在右边的面板中,它显示了数据的直方图,从我们的角度来看,更重要的是,它使用 StatsR 函数get_descriptive statistics
生成汇总统计数据。清单 6-15 显示了代码。
-
Listing 6-15Displaying summary statistics
汇总统计数据stats
呈现在 UI fluidPage
中声明的汇总面板上。数据生成后,我们将其提取为一个单独的列NumericVector
。这以通常的方式与代表我们想要返回的汇总统计信息的键一起传递给get_descriptive_statistics
。呈现结果需要多几行代码。首先,我们将结果转换成一个DataFrame
,并格式化数值。然后,我们将结果转换成表格格式并返回。可以看出,我们的 StatsR 包可以或多或少地与其他 R 包无缝协作。
摘要
在这一章中,我们已经编写了一个连接到本地 C++ 库的全功能 R 包。我们已经公开了底层库中的函数和类,以便它们可以在 R/RStudio 中使用。我们已经测试了功能并对其进行了基准测试。
一旦我们有了这些东西(一个 RStudio Rcpp 项目,可用于编译和构建的 Rtools,以及一个 C++ 开发环境),就没有什么可以阻止我们使用公共领域 C++ 库中提供的任何分析作为 R 数据分析工具链的一部分。例如,我们可以使用 QuantLib ( www.quantlib.org/
)并在 R 中使用一些利率曲线构建功能。或者,我们可以考虑开发我们自己的 C++ 库,并在 R 中提供这些库。值得强调的是,这超出了编写少量 C++ 代码的传统用例,这些代码在 R 中编译并内联运行,以提高性能。这两章为更系统地开发 C++ 组件提供了一个工作基础结构,目的是使 R 包中的功能可用。Rcpp 使这一过程无缝衔接,并消除了许多相关的工作。在接下来的两章中,我们将看到类似的情况,但是在这种情况下,我们的重点是 Python 语言和 Python 客户端。
额外资源
以下链接更深入地介绍了本章涵盖的主题:
- Rcpp 是一个大型库。为了方便用户的介绍,我推荐在
https://teuder.github.io/rcpp4everyone_en/
的《人人 Rcpp》。这里有官方的包文档:https://cran.r-project.org/web/packages/Rcpp/Rcpp.pdf
。但是,根据您要查找的信息类型,还有各种其他来源。除了《与 Rcpp 的无缝 R 和 C++ 集成》(参见参考资料)这本书之外,在https://github.com/RcppCore/Rcpp
还有大量的文档涵盖了这个包的所有方面。特别推荐的是专注于 Rcpp 具体特性的简介(例如,像 RcppModules)。我还会推荐https://cran.r-project.org/web/packages/Rcpp/vignettes/Rcpp-FAQ.pdf
的 Rcpp 常见问题。
练习
本节中的练习涉及将我们对底层代码库所做的各种更改合并到 R 包中,并通过 Rcpp 公开功能。所有练习都使用 StatsR RStudio 项目。
1)我们扩展了LinearRegression
函数来计算相关系数 r 和r2,并将它们添加到结果包中。确认显示了在LinearRegression
功能中计算的附加系数,并检查数值。
为此,您可以使用脚本 LinearRegression。R 。要检查结果,使用功能cor(data)
和cor(data)²
。将这些值与从函数StatsR::linear_regression(...)
的结果包中获得的值进行比较。结果应该是相同的。
在 test_linear_regression 中扩展测试用例。R 包括对这些值的检查。
2)TimeSeries
类已经被添加到源代码中,并内置到 libStatsLib.a 静态库中(参见第五章)。从TimeSeries
类中公开MovingAverage
函数。在这种情况下,我们只想公开一个过程包装函数。在下一个练习中,我们将使用 RcppModules 添加一个类。
所需的步骤如下:
-
在 \src 目录下添加一个新文件 TimeSeries.cpp 。使用文件➤新➤ C++ 文件,因为这将创建带有样板 Rcpp 代码的文件。
-
\Common\include 目录下的 TimeSeries.h 文件。
-
使用过程包装器公开
MovingAverage
方法。建议使用以下函数签名: -
实施代码:
-
将日期转换成一个矢量
long
s。 -
将观察值转换成一个矢量
double
s。 -
构造一个
TimeSeries
类的实例。 -
调用
MovingAverage
函数并返回结果。
-
-
选择 Build ➤ Clean and Rebuild,检查构建是否(仍然)正常工作,没有警告或错误。检查输出中的文件 \src\TimeSeries.cpp 是否编译正确。检查该功能是否出现在RCP exports 中。R 。
-
检查该函数是否出现在函数列表中。使用
> library(pkgload) > names(pkg_env("StatsR"))
std::vector<double> get_moving_average(Rcpp::NumericVector dates, Rcpp::NumericVector observations, int window) { ... }
3)添加一个 R 脚本时间序列。R 来练习新功能。
-
创建一些随机数据如下:
n = 100 # n samples observations <- 1:n + rnorm(n = n, mean = 0, sd = 10) dates <- c(1:n)
-
添加一个简单的移动平均函数,默认窗口大小为 5:
moving_average <- function(x, n = 5) { stats::filter(x, rep(1 / n, n), sides = 1) }
-
获取两个移动平均值:一个来自 StatsR 包,另一个使用本地函数(注意窗口大小参数):
my_moving_average_1 <- StatsR::get_moving_average(dates, observations, 5) my_moving_average_2 <- moving_average(observations, 5) # Apply user-defined function
-
绘制系列图。
-
比较系列,因为它们应该是相同的:
equal <- (my_moving_average_1 - my_moving_average_2) >= (tolerance - 0.5) length(equal[TRUE])
4)为三个 z 测试函数添加过程包装器。这些应该类似于 t-test 包装,即:
-
选择 Build ➤ Clean and Rebuild,检查构建是否正常工作,没有警告或错误。检查输出中的文件\ src \ statisticaltests . CPP是否编译正确。检查功能是否出现在RCP exports 中。R 。检查函数列表中是否有这些函数。
-
使用 R 脚本 StatisticalTests。R 编写一个脚本来行使这个新功能。以下脚本使用的数据与本机 C++ 单元测试、C# 单元测试和 Excel 工作表中使用的数据相同:
# # z-tests # # Summary data z-test StatsR::z_test_summary_data(5, 6.7, 7.1, 29) # One-sample z-test data StatsR::z_test_one_sample(3, c(3, 7, 11, 0, 7, 0, 4, 5, 6, 2)) # Two-sample z-test data x <- c( 7.8, 6.6, 6.5, 7.4, 7.3, 7.0, 6.4, 7.1, 6.7, 7.6, 6.8 ) y <- c( 4.5, 5.4, 6.1, 6.1, 5.4, 5.0, 4.1, 5.5 ) StatsR::z_test_two_sample(x, y)
-
为了完整起见,将测试用例添加到\ test that \ test _ statistical _ tests 中。R 。
-
运行测试。R 编写脚本并确认所有测试都通过了。
z_test_summary_data(...)
z_test_one_sample(...)
z_test_two_sample(...)
5)在 StatsR 项目下的 \man 目录下,有一个名为 StatsR-package 的 R markdown 文档。Rd 。用新功能更新文档:get_moving_average
、z_test_summary_data
、z_test_one_sample
和z_test_two_sample
。
- 选择预览以查看更改。选择构建➤清理并重建。检查文件:" D:\ R \ R-4 . 0 . 3 \ library \ StatsR \ html \ StatsR-package . html "。
6)将ZTest
作为一个类添加到 RcppModule StatsTests
中。
-
在 StatisticalTests.cpp 中,编写一个包含私有成员变量的包装类:
-
实现构造函数中所需的转换。这与
TTest
包装器基本相同。 -
将此类添加到 RcppModule:
... { Rcpp::class_<ZTest>("ZTest") .constructor<double, double, double, double>("Perform a z-test from summary input data") .constructor<double, Rcpp::NumericVector >("Perform a one-sample z-test with known population mean") .constructor<Rcpp::List >("Perform a two-sample z-test") .method("Perform", &ZTest::Perform, "Perform the required test") .method("Results", &ZTest::Results, "Retrieve the test results") ; }
-
在 RStudio 中,选择 Build ➤ Clean and Rebuild,并检查构建是否正常工作,没有出现警告或错误。检查输出中的文件\ src \ statisticaltests . CPP是否编译正确。
-
使用 R 脚本 StatisticalTests。R 写一个脚本来练习新的类。下面是汇总数据 z 检验的一个例子:
library(Rcpp) library(formattable) moduleStatsTests <- Module("StatsTests", PACKAGE="StatsR") ztest0 <- new(moduleStatsTests$ZTest, 5, 6.7, 7.1, 29) if(ztest0$Perform()) { results <- ztest0$Results() print(results) results <- as.data.frame(results) formattable(results) } else { print("Z-test from summary data failed.") }
Stats::ZTest _ztest;
7)将TimeSeries
作为一个类添加到一个新的 RcppModule。
-
打开 TimeSeries.cpp 源文件。
-
为本机 C++ 时间序列添加一个包装类,如下所示:
// A wrapper class for time series class TimeSeries { public: ~TimeSeries() = default; TimeSeries(Rcpp::NumericVector dates, Rcpp::NumericVector observations) : _ts(Rcpp::as<std::vector<long> >(dates), Rcpp::as<std::vector<double> >(observations) ) {} std::vector<double> MovingAverage(int window) { return _ts.MovingAverage(window); } private: Stats::TimeSeries _ts; };
-
定义一个描述包装类的
RCPP_MODULE(TS)
,例如:Rcpp::class_<TimeSeries>("TimeSeries") .constructor<Rcpp::NumericVector, Rcpp::NumericVector>("Construct a time series object") .method("MovingAverage", &TimeSeries::MovingAverage, "Calculate a moving average of size = window") ;
-
选择 Build ➤ Clean and Rebuild,检查构建是否正常工作,没有警告或错误。
-
打开文件 TimeSeries。R 。向脚本中添加代码,计算与前面相同的时间序列并比较结果。
moduleTS <- Module("TS", PACKAGE="StatsR") ts <- new(moduleTS$TimeSeries, dates, observations) my_moving_average_4 <- ts$MovingAverage(5) equal <- (my_moving_average_4 - my_moving_average_2) >= (tolerance - 0.5) length(equal[TRUE])
七、构建 Python 扩展模块
介绍
在这一章和下一章中,我们将着眼于构建 Python 扩展模块。这些是将 C/C++ 连接到 Python 的组件。Python 已经存在很长时间了,多年来,已经开发了许多不同的方法来实现这一点。表 7-1 列出了一些方法。
表 7-1
将 C/C++ 连接到 Python 的方法 1
|方法
|
过时的
|
代表性用户
|
| --- | --- | --- |
| 【CPython 的 C/C++ 扩展模块 | One thousand nine hundred and ninety-one | 标准程序库 |
| PyBind11(推荐用于 C++) | Two thousand and fifteen | |
| Cython(建议用于 C) | Two thousand and seven | 盖文,基维 |
| 持有期收益率 | Two thousand and nineteen | |
| mypyc | Two thousand and seventeen | |
| ctypes(类型) | Two thousand and three | oscrypto |
| 财务信息管理系统 | Two thousand and thirteen | 密码学,pypy |
| 大喝 | One thousand nine hundred and ninety-six | 你们这些混蛋 |
| 助推。Python | Two thousand and two | |
| cppyy | Two thousand and seventeen | |
在本章和接下来的章节中,我们将重点介绍三种主要的方法。这些在表 7-1 中突出显示。在本章中,我们从一个使用 CPython 的“原始”Python 项目开始。这是有教育意义的。我们将看到如何从头开始建立一个 Python 扩展模块项目,以及如何从我们的小型统计库中公开功能。这让我们有机会了解模块是如何定义的,以及PyObject
是如何在转换层中使用的。它还说明了低级方法的一些困难。第章第 8 关注助推。Python 然后是 PyBind。这两个框架都提供了有用的工具,克服了我们在编写 CPython 扩展模块时面临的一些问题。我们还将研究如何公开类和函数。最后,我们使用我们构建的模块来说明检查对象和测量性能以及其他事情。
先决条件
对于本章和下一章,主要的先决条件是 Python 安装( www.python.org/downloads/
)。对于这本书,我们使用 Python 3.8(这个项目开始时可用的最新版本)。除了 Python 的版本,我们还需要了解构建环境。在下一章中,我们将需要 Boost。Python 和 Boost。Python 库需要针对这个相同版本的 Python 来构建。
使用 Visual Studio 社区版 2019
在同一个解决方案中,使用 Visual Studio 管理 C++ 项目(我们这样做)和 Python 项目(我们不这样做)是完全可能的。这样做的好处是,您可以在开发 C++ 组件的同一环境中调试 Python 脚本。然而,这种设置有一个缺点。它将我们与 Visual Studio Community Edition 2019 针对的 Python 版本(目前为 Python 3.7)联系起来。而这反过来又会导致 Python、Boost 版本的不一致。Python 和 C++ 项目。为了开发 Python 模块,我们确实需要将 Python (3.8)版本和 Boost 库的 Boost 发行版结合起来。Python(使用 Python 3.8 构建)。
因此,这里的建议是将两个开发领域分开。我们对 C++ 包装器组件使用 Visual Studio Community Edition 2019,对 Python 项目和脚本使用 VSCode。这意味着我们可以方便地使用 MSBuild 编译扩展模块,而不必编写自己的安装和构建脚本。这种方法的优点是使调试稍微容易一些,尽管不如使用完全混合模式调试那样无缝。
StatsPythonRaw
我们的第一个扩展模块是一个名为 StatsPythonRaw 的“原始”Python 项目。我们首先看看项目设置,然后看看代码是如何组织的。在此过程中,我们将研究如何公开底层统计库的函数以及类型转换层。我们还处理异常处理。在最后一节中,我们将练习 Python 客户机的功能,并研究如何调试扩展模块。
项目设置
StatsPythonRaw 是作为 Windows 动态链接库(DLL)项目创建的。该项目引用 StatsLib 静态库。表 7-2 总结了项目设置。
表 7-2
StatsPythonRaw 的项目设置
|标签
|
财产
|
价值
|
| --- | --- | --- |
| 一般 | C++ 语言标准 | ISO C++17 标准版(/std:c++17) |
| 先进的 | 目标文件扩展名 | 。pyd |
| C/C++ >常规 | 其他包含目录 | <用户\用户>\蟒蛇 3 \包含**$(解决方案目录)通用\包含 |
| 链接器>常规 | 附加库目录 | <用户\用户> \Anaconda3\libs |
| 生成事件>后期生成事件 | 命令行 | 请参见下文 |
以下几点值得注意。首先,我们将目标输出从 dll 改为 pyd 。这表明输出是一个 Python 扩展库。其次,我们需要注意 Python 安装的位置。在附加的包含目录中,我们引用了可以找到 Python.h 的 \include 目录。在附加的库目录中,我们引用了 \libs 目录(而不是 \Lib 或 \Library ,它们也存在于 Python 发行版中)。这就是可以找到 python38.lib 的地方。最后,我们将statspithonraw . pyd模块复制到 Python 脚本( *)所在的目录中。py )导入它所在的位置。我们在后期构建步骤中使用以下脚本:
del "$(SolutionDir)StatsPython\$(TargetName).pyd"
copy /Y "$(OutDir)$(TargetName)$(TargetExt)" "$(SolutionDir)StatsPython\$(TargetName)$(TargetExt)"
这简化了 Python 的设置。通过将 pyd 文件复制到脚本将要执行的位置,我们避免了必须调用 setup.py 来将 Python 模块安装到 Python 环境中。在生产场景中,这是必需的。然而,为了便于说明,我们走这条捷径。
代码组织
在 StatsPythonRaw 项目下,代码被组织成三个主要区域:我们想要公开的函数(functions . h/functions . CPP)、转换层(conversion . h/conversion . CPP)以及我们正在构建的扩展模块( module.cpp )。我们将依次处理这些问题。
功能
声明
在文件 Functions.h 中,我们声明了用于公开底层功能的包装函数。为了方便起见,我们将所有函数放在一个名为API
的名称空间中。清单 7-1 再现了完整的声明。
-
Listing 7-1Declaration of the wrapper functions we want to expose
在清单 7-1 中,文件顶部的预处理宏很重要。Python 文档(参见附加参考资料部分)建议我们在包含任何标准库头文件之前使用#define PY_SSIZE_T_CLEAN
。例如,当使用有大小的对象、列表和数组时,宏指的是 size 变量的类型。如果定义了宏,那么大小类型是Py_ssize_t
,否则大小类型是int
。接下来,我们有一些构建指令。我们希望能够构建这个扩展模块的调试和发布版本。但是,我们不想链接调试版本的 Python 库,因为我们还没有安装它们。如果没有这个预处理器指令,当我们构建 StatsPythonRaw 的调试版本时,链接器将试图链接到 python38_d.lib 。因为我们没有这个,所以会产生一个构建错误。所以,我们被要求UNDEF
对准_DEBUG
符号。如果您下载并安装 Python 调试库,您可以删除它。最后,#include <Python.h>
引入 Python API。
在API
名称空间中,所有的 C++ 包装函数都返回一个PyObject
指针。这可以被认为是一个不透明的类型或句柄。通常,Python 运行时将参数作为PyObject
传递给我们,然后我们需要解释这些参数。当我们从函数返回时,我们需要将PyObject
返回到 Python 运行时。具体来说,从清单 7-1 中我们可以看到,包装函数总是接受两个PyObject
参数,习惯上称为self
和args
。self
参数指向模块级函数的模块对象(这里就是这种情况);对于类方法,它指向对象实例(即调用调用的对象)。我们在这个项目中不使用这个论点,所以我们忽略它。对于args
参数,我们区分两种情况。对于只有一个参数的函数,这将直接在 Python 对象中传递。在函数有多个参数的情况下,args
参数指向一个tuple
对象。tuple
的每一项对应调用的参数列表中的一个参数。我们将在本章后面讨论如何解释tuple
。
描述统计学
看了函数声明之后,我们现在来看函数定义。我们从想要公开的最简单的函数开始。在底层 C++ 库中,函数GetDescriptiveStatistics
(在 \StatsLib\Stats.h 中)被声明为接受两个参数,其中第二个是可选的。我们想从 Python 中调用这个函数,如清单 7-2 所示。
-
Listing 7-2Calling the DescriptiveStatistics function from Python
从清单 7-2 ,即交互式 Python 会话,我们可以看到,我们首先用一个参数(data
)调用函数,然后用两个参数(data
、keys
)。清单 7-3 显示了相应的 C++ 包装函数定义。
-
Listing 7-3The definition of the DescriptiveStatistics function
该函数的结构很简单。第一部分涉及从args
元组中提取PyObject
指针。第二部分包括为底层 C++ 层翻译这些并返回结果。Python API 中的函数PyArg_ParseTuple
检查参数类型,并将它们转换成 C/C++ 值。它使用一个模板字符串来确定所需的参数类型以及存储转换值的 C/C++ 变量的类型。模板字符串决定了元组如何解包它的参数。在这种情况下,我们告诉它有两个PyObject
指针,这是由O
(大写字母)表示的。分隔"O|O"
字符串的“|
表示第二个参数是可选的。稍后,我们将看到更多使用模板字符串的例子。下面的列表总结了模板字符串中使用的一些更常见的参数类型。
字符串
|
转换
|
| --- | --- |
| “我” | 将 Python 整数转换成int
。 |
| " l " | 将 Python 整数转换成long
。 |
| " d " | 将一个 Python 浮点数转换成一个double
。 |
| “哦” | 在一个PyObject
指针中存储一个 Python 对象。 |
模板字符串中参数类型及其用法的完整列表如下: https://docs.python.org/3/c-api/arg.html
。
在这种情况下,正如我们所说的,两个参数都是 Python 对象。为了对它们做任何事情,我们的函数必须将它们转换成 C/C++ 类型。这里,使用函数ObjectToVector
将第一个参数转换为std::vector<double>
。类似地,我们使用ObjectToStringVector
将第二个参数从PyObject
转换为std::vector<std::string>
。转换后的对象(_data
、_keys
)被传递给本机 C++ 函数,结果被打包为std::unordered_map
中的键值对返回。这些然后被转换回一个指向PyObject
的指针,并返回给调用者。
线性回归
在更详细地查看转换函数之前,我们先来看看更多我们想要公开的函数。清单 7-4 显示了LinearRegression
的包装函数。
-
Listing 7-4The wrapper function for LinearRegression
我们可以在清单 7-4 中看到,这个函数遵循与我们之前看到的DescriptiveStatistics
函数相似的结构。然而,在这种情况下,args
参数包含两个非可选项目。因此,模板字符串是"OO"
。这些代表执行操作所需的两个数据集。在调用原生 C++ 函数之前,我们需要将args
元组解包成有效的PyObject
、xs
和ys
。然后我们需要将每一项转换成适当的 C++ 类型。一旦完成,我们调用底层的 C++ 函数并返回结果。
统计测试
对于统计测试函数,我们以与前面相同的方式构造函数。然而,在这种情况下,包装函数在堆栈上创建了一个TTest
类的实例。为此,它需要将参数传递给相应的构造函数。清单 7-5 中显示了一个这样的例子。
-
Listing 7-5The SummaryDataTTest wrapper function
到目前为止,我们已经看到了PyObject* args
元组被传入PyArg_ParseTuple
,参数被提取为PyObjects
。然而,在清单 7-5 中,我们利用了标准转换。来自样本数据构造器的 t-test 需要四个double
。因此,我们使用带有模板字符串"dddd"
的PyArg_ParseTuple
来解包args
元组,以表示四个双精度值。因为这些是内置类型,函数PyArg_ParseTuple
隐式地转换它们。无需进一步转换。然后,该函数继续创建TTest
实例,并调用Perform
进行计算,然后调用Results
获得结果包。然后将它转换回 Python 字典。
OneSampleTTest
和TwoSampleTTest
的处理方式相似。OneSampleTTest
如清单 7-6 所示。
-
Listing 7-6The OneSampleTTest wrapper function
从清单 7-6 和之前的清单中,我们可以看到函数OneSampleTTest
和TwoSampleTTest
的相似结构。我们首先声明我们期望从args
中得到的类型。然后我们使用带有适当模板字符串的PyArg_ParseTuple
将参数解包成内置类型或PyObject
指针。然后,在将结果返回给 Python 之前,我们进行所需的任何进一步的转换。在OneSampleTTest
的情况下,模板字符串是"dO"
,表示第一个参数是 double,第二个参数是PyObject
。因此,我们对第一个参数(double mu0
)使用标准转换,并将第二个参数解包为一个PyObject
指针,然后它被转换为一个std::vector<double>
,如我们之前所见。
转换层
我们已经看到,对于内置类型(bool
、int
、double,
等)。),我们不需要做什么特别的事情。转换由PyArg_ParseTuple
使用适当的模板字符串参数来处理。对于 STL 类型,转换层(conversion . h/conversion . CPP)为类型转换逻辑提供了一个中心位置。只有三个功能。一个用于将代表 Python 的PyObject
转换为std::vector<double>
。第二个函数是将代表 Python 字符串的PyObject
转换成std::vector<std::string>
。最后,我们有一个函数将结果(字符串键和数值的无序映射)转换成一个PyObject
指针。清单 7-7 显示了ObjectToVector
功能。
-
Listing 7-7Converting a PyObject to a std::vector<double>
查看清单 7-7 中的代码,我们看到从 Python 对象到 STL 类型的转换非常简单。我们期望来自 Python 的参数是一个list
,所以我们需要使用PyList_xxx
函数。首先,我们获取输入列表的大小,然后使用 for 循环提取每一项。我们使用函数PyList_GetItem
来检索索引数据项,并根据需要将其从 Python 编号转换为double
( PyFloat_AsDouble
)。然后将其放入std::vector<double>
中。当循环完成时,数据被返回给调用者。
清单 7-8 显示了三个函数中的第二个,ObjectToStringVector
。
-
Listing 7-8The ObjectToStringVector function
清单 7-8 中显示的ObjectToStringVector
函数将 Python 列表转换为字符串向量。我们可以看到这和前面的函数类似。在这种情况下,我们首先检查输入对象是否有效。我们知道这里的PyObject
代表一个可选参数,所以参数有可能是 null。在前一种情况下,如果不提供参数,函数PyArg_ParseTuple
就会失败,所以检查是多余的。然而,我们应该意识到,如果我们扩展了ObjectToVector
函数的用法(特别是允许可选数据),那么我们需要改变这一点。检查之后,我们继续从列表中提取有效的PyObject
。这种情况下的不同之处在于,我们需要将其转换为字符串。为了简单起见,我们不检查 Python 字符串是 Unicode UTF-8 还是 UTF-16。我们简单地假设 UTF-8 并使用 Python 函数PyUnicode_1BYTE_DATA
将字符串转换成char*
,然后在std::string
的构造函数中使用它。执行转换的一种更健壮的方法是检查各种可能的 Python 类型,并相应地处理这些情况。以一种通用的方式处理字符串,并在一个跨平台的环境中工作,这是一个非常大的话题,超出了本章的范围。
清单 7-9 展示了最后一个函数MapToObject
,它将结果地图转换为 Python 字典。
-
Listing 7-9Converting the underlying results package to a Python dictionary
正如我们在清单 7-9 中看到的,MapToObject
函数比以前稍微复杂一些。在这种情况下,我们将对结果图的引用作为输入。代码做的第一件事是创建一个新的 Python 字典。然后,我们迭代结果项,并将每个项插入字典中。密钥是字符串,所以我们使用PyUnicode_FromString
来执行转换。为了获得该值,我们使用PyFloat_FromDouble
(就像我们之前做的那样)。最后,我们将提取的键值对设置为一个字典项,并检查这是否成功。在这种情况下,我们需要减少键和值的引用计数,因为我们不再使用PyObject
引用。我们使用Py_XDECREF
宏,它允许对象为空,而不是Py_DECREF
。如果PyDict_SetItem
没有成功,我们还需要减少字典的引用计数,并返回nullptr
以向 Python 运行时指示失败。
这个上下文中的引用计数非常复杂,但是为了避免 Python 的内存泄漏,引用计数是必需的。但是,对引用计数的全面讨论超出了本章的范围。Python 文档包含关于该主题的有用部分。
现在很清楚,转换层可以做得更通用,从而得到改进。我们的三个转换函数完全特定于底层原生 C++ 层的需求。如果能对一些代码进行一般化就好了。特别是,使用 RAII(资源获取是初始化)来管理引用计数似乎是一种有用的方法。此外,处理默认参数以及在标准库容器和模板之间进行转换也是有益的。虽然这可能很诱人,但在 C++ 和 Python 之间编写一个“通用”转换层可能很难做到,而且实现起来很耗时。附加资源部分提供了许多关于这些主题的参考资料。幸运的是,正如我们将在下一章看到的,两者都有所促进。Python 和 PyBind 在这方面做得非常出色。
错误处理
我们知道 StatsLib 抛出异常。如果我们不处理这些,有可能从 Python 脚本中,我们只是终止了 Python 外壳。这不一定是我们想要的。因此,和以前一样,我们使用代码将STATS_TRY/STATS_CATCH
宏封装到函数调用中,将std::exception
转换成 Python 可以解释的信息字符串。
STATS_TRY/STATS_CATCH
宏的定义如清单 7-10 所示。
-
Listing 7-10Handling exceptions
在清单 7-10 中,在一个异常被抛出后,我们首先使用PyErr_SetString
函数指出一个错误情况。根据文档,传递给这个函数的对象不需要Py_DECREF
(https://docs.python.org/3/extending/extending.html#refcounts
)。第一个参数是 Python 异常对象,第二个是来自 C++ 函数的信息字符串。第二阶段是通过返回nullptr
来指示失败。如果我们想更进一步,我们可以为这个模块创建一个标准的异常,或者我们可以用一个自定义类来扩展异常处理。然而,对异常处理主题的全面讨论超出了本章的范围。附加资源部分提供了更多的参考资料。
既然我们在 C++ 层处理异常,我们可以在 Python 脚本中添加等效的异常处理。例如,我们将DescriptiveStatistics
函数包装在try/except
块中,并报告任何异常。报告异常后,代码继续正常执行。清单 7-11 显示了代码。
-
Listing 7-11Reporting exceptions from Python
清单 7-11 中的代码很简单。我们接受包装函数抛出的异常,并输出信息字符串。一个典型的交互式会话展示了这种情况是如何出现的:
>>> import StatsPythonRaw as Stats
>>> data = []
>>> print(Stats.DescriptiveStatistics(data))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
RuntimeError: Insufficient data to perform the operation.
正如我们所看到的,我们捕获并报告 C++ 异常,并将其返回给 Python,在 Python 中我们可以继续交互会话。
模块定义
到目前为止,我们已经讨论了调用函数和类型转换。Python 扩展模块组件需要的最后一部分是模块定义。代码位于 module.cpp 中。代码由三个主要部分组成:导出函数、模块定义和初始化函数。我们将依次了解每一项。清单 7-12 显示了导出的函数。
-
Listing 7-12Exporting functions from the module
导出的函数(列表 7-12 )在一个struct
中定义,它包含一个我们想要导出的函数数组。参数很简单。第一个参数是向 Python 公开的函数的名称。第二个参数是实现它的函数。该功能必须符合typedef
:
typedef PyObject *(*PyCFunction)(PyObject *, PyObject *)
这声明了一个函数,它将两个PyObject
指针作为参数,并返回一个PyObject
指针。这对应于我们如何声明我们的函数(参见清单 7-1 )。第三个参数是描述函数所期望的args
的METH_xxx
标志的组合。对于单参数函数,我们将使用METH_O
。这意味着该函数只接受一个PyObject
参数。采用METH_O
的函数直接在args
参数中传递PyObject
。所以,没必要用PyArg_ParseTuple
。METH_VARARGS
表示函数接受可变数量的参数,这些参数需要由PyArg_ParseTuple
解包。最后一个参数是 Python docstring 属性(__doc__
),也可以是一个nullptr
。
导出的函数列表后面是模块定义结构,如清单 7-13 所示。
-
Listing 7-13The module definition structure
清单 7-13 所示的PyModuleDef
结构定义了模块的结构。我们用符号PyModuleDef_HEAD_INIT
初始化这个结构。接下来,我们提供用于 Python 导入语句的模块名,后面是模块描述。我们使用的最后一个参数是指向定义导出方法的结构的指针(清单 7-12 中定义的结构)。按照模块定义,我们用模块的名字定义函数PyInit_StatsPythonRaw
。这与模块定义中声明的名称相匹配是很重要的(即“statspithornaw”)。Python 运行时环境将寻找这个函数来调用,以便在这个模块被import
调用时执行这个模块的初始化。这就是这个组件。我们现在可以建造它。它应该不会出现警告或错误。输出文件(statsphythonraw . pyd)将被复制到 StatsPython 项目中,我们可以在 Python 脚本中导入它。
Python 客户端
既然我们已经有了一个封装了本地 C++ 函数的 Python 扩展模块,我们就可以用我们选择的任何方式来使用它。我们可以推出一款 Jupyter 笔记本,并在另一个项目中使用我们的功能。或者作为对我们已经公开的函数的快速测试,我们也可以运行 Python 的 shell 并交互式地使用读取-评估-打印循环(REPL)。清单 7-14 显示了一个小例子。
-
Listing 7-14Performing a summary data t-test
正如我们从清单 7-14 中看到的,这提供了一种简单的方法来实现这些功能。作为一个稍微复杂一点的替代方案,我们可以使用脚本statspithornaw . py。这提供了更广泛的功能测试。该脚本定义了一个主函数,因此我们可以直接从命令提示符(> python StatsPythonRaw.py
)运行它。例如,我们也可以直接在 Visual Studio 代码中打开它,并执行它(F5)。主要功能如清单 7-15 所示。
-
Listing 7-15The main function exercising StatsPythonRaw
清单 7-15 中的脚本定义了我们在别处使用过的xs
和ys
数据集。我们将这些作为描述性统计和线性回归函数的输入。接下来,我们调用ttest_summary_data
函数。代码的最后一部分使用 Pandas 将数据加载到数据框中。使用这些数据,我们进行了单样本 t 检验。最后,我们加载 us-mpg 和 jp-mpg 数据集。这些数据集与我们在 StatsViewer MFC 应用程序中使用的数据集相同。我们使用 matplotlib 可视化箱线图中均值的差异(图 7-1 ),之后我们执行双样本 t 检验。
图 7-1
对比美国和日本汽车汽油消耗的箱线图
排除故障
调试 Python 和 C++ 出人意料地简单。VSCode IDE 可以很好地处理 Python 调试。调试 C++ 代码只需在 VSCode 中的 Python 脚本中放置一个断点(Python 执行将在那里暂停),然后使用 Visual Studio(调试➤附加到进程...)附加到正确的 Python 托管进程(您可以使用procexp64.exe来轻松识别这一点)。然后,脚本将跳转到 C++ 代码中,在适当的位置中断(假设您正在调用 C++ 模块的调试版本)。从这里开始,您可以逐句通过 C++ 代码。这只是许多可能性中的一种。附加资源部分提供了更多的细节。
摘要
在本章中,我们从头开始构建了一个基本的 Python 模块。我们首先公开底层 StatsLib 中的函数,然后根据需要转换类型。最后,我们定义了模块。这可以被认为是我们公开的函数和参数的声明性描述(元数据)。在使用 Python 模块方面,我们创建了一个简单的脚本。除了练习一些功能,我们还演示了模块如何与 Pandas 和 matplotlib 互操作。
正如我们在本章的介绍中指出的,使用“原始”CPython 编写一个模块是有益的,因为它说明了使用低级方法将 C/C++ 连接到 Python 所涉及的一些困难。我们不得不编写特定的代码来处理我们需要的转换。此外,我们已经看到,每当我们与PyObject
s 交互时,我们都需要注意引用计数。Python 和 PyBind 框架,看看这两者如何减轻我们在这里看到的困难。
额外资源
以下资源有助于了解本章所涵盖主题的更多信息:
-
所有与 Python 相关的东西,优秀的 Python 文档都可以在
https://docs.python.org/3/
找到。对编写扩展模块特别感兴趣的是关于用 C 或 C++ 扩展 Python 的部分。这个推荐:https://docs.python.org/3/extending/extending.html
。有关构建和安装 Python 模块的更多信息,请参见https://docs.python.org/3/extending/building.html#building
和https://docs.python.org/3/extending/windows.html#building-on-windows
。 -
以下文档提供了与 Python 扩展模块相关的各种主题的宝贵信息:
https://pythonextensionpatterns.readthedocs.io/_/downloads/en/latest/pdf/
。此外,它涵盖了将字符串从 Python 转换为std::string
的健壮方法。 -
这里讨论一般化类型转换层和提供健壮的生存期管理的方法:
https://pythonextensionpatterns.readthedocs.io/en/latest/cpp_and_cpython.html
。其他方法的例子可以在https://github.com/mfontanini/Programs-Scripts/blob/master/pywrapper/pywrapper.h
和https://github.com/dubzzz/Py2Cpp/blob/master/src/py2cpp.hpp
中找到。 -
在异常处理方面,这里描述了为一个模块创建一个标准异常类型的方法:
https://docs.python.org/3/extending/extending.html
。用自定义类扩展异常处理这里描述:https://pythonextensionpatterns.readthedocs.io/en/latest/exceptions.html
。 -
在 Visual Studio 中调试混合模式 C/C++ Python 代码这里介绍:
https://docs.microsoft.com/en-us/visualstudio/python/debugging-mixed-mode-c-cpp-python-in-visual-studio?view=vs-2019
。
练习
本节中的练习通过我们的 CPython 扩展模块向 Python 展示新的 C++ 功能。
1)为 z 测试函数添加过程包装。这些函数应该与 t-test 函数几乎相同。不需要额外的转换函数,因此您可以用一种简单的方式来修改 t-test 函数。
-
在 Functions.h,中添加三个函数的声明:
// Wrapper function for a z-test with summary input data (no sample) PyObject* SummaryDataZTest(PyObject* /* unused module reference */, PyObject* args); // Wrapper function for a one-sample z-test with known population mean PyObject* OneSampleZTest(PyObject* /* unused module reference */, PyObject* args); // Wrapper function for a two-sample z-test PyObject* TwoSampleZTest(PyObject* /* unused module reference */, PyObject* args);
-
在 Functions.cpp 中,添加这些函数的实现。遵循 t-test 包装函数的代码。
-
在 module.cpp 中,向数组添加三个新函数:
static PyMethodDef StatsPythonRaw_methods[] = { //... }
-
构建 StatsPythonRaw。它的构建应该没有警告和错误。这些函数现在应该可以从 Python 中调用了。以交互方式尝试这些功能,例如:
>>> import StatsPythonRaw as Stats >>> results: dict = Stats.SummaryDataZTest(5, 6.7, 7.1, 29) >>> print(results) {'z': 1.2894056580462898, 'sx1': 7.1, 'pval': 0.19725709541241007, 'x1-bar': 6.7, 'n1': 29.0}
-
在 VSCode 中打开 StatsPython 项目。打开statspithonraw . py脚本。添加函数以使用我们之前使用的数据测试 z 测试函数。
2)添加一个过程包装函数来计算一个简单的MovingAverage
。我们从之前的练习中知道,在处理时间序列移动平均函数时,我们需要添加一些转换函数。具体来说,在这个 Python 例子中,我们需要将一个PyObject
转换成一个long
的向量。此外,我们需要将来自std::vector<double>
的结果转换成一个 Python list
。
添加转换函数所需的步骤如下:
-
在 Conversion.h,中添加一个转换函数的声明:
std::vector<long> ObjectToLongVector(PyObject* o);
-
在 Conversion.cpp 中,添加实现。这类似于
ObjectToVector
,但是它使用PyLong_AsLong
从PyObject
中提取长值。
类似地,我们需要将结果(一个矢量double
s)转换成一个 Python list
。
-
在 Conversion.h 中,添加声明:
PyObject* VectorToObject(const std::vector<double>& results);
-
In Conversion.cpp, add the following implementation:
PyObject* VectorToObject(const std::vector<double>& results) { const std::size_t size = results.size(); PyObject* list = PyList_New(size); for(std::size_t i = 0; i < size; ++i) { double d = results[i]; int success = PyList_SetItem(list, i, Py_BuildValue("d", d)); if (success < 0) { Py_XDECREF(list); return nullptr; } } return list; }
在这种情况下,我们使用输入向量大小创建一个新的 Python
list
。为了设置列表项,我们使用了函数PyList_SetItem
。我们使用模板字符串"d"
(用于double
)将便利函数Py_BuildValue
返回的PyObject
传递给它。
转换函数就绪后,编写包装函数。编写包装函数所需的步骤如下:
-
在 Functions.h,中增加一个声明:
PyObject* MovingAverage(PyObject* /* unused module reference */, PyObject* args);
-
在 Functions.cpp 中,有许多细节:
-
将
#include "TimeSeries.h"
添加到文件的顶部。 -
添加实现。该函数采用三个非可选参数。日期列表、观察列表和窗口大小。
-
添加异常处理程序
STATS_TRY/STATS_CATCH
。 -
声明输入参数:
PyObject* dates = nullptr; PyObject* observations = nullptr; long window{ 0 };
-
用模板字符串
"OOl"
解析输入args
。 -
像以前一样转换输入以构建时间序列。
-
使用
VectorToObject
转换功能返回结果。
-
-
Finally, in module.cpp, add the new function to the list of exposed functions:
{ "MovingAverage", (PyCFunction)API::MovingAverage, METH_VARARGS, "Compute a simple moving average of size = window." },
这就完成了为
MovingAverage
函数添加过程包装器的代码。 -
构建 StatsPythonRaw。它的构建应该没有警告和错误。
-
以交互方式尝试该功能,例如:
>>> import StatsPythonRaw as Stats >>> dates: list = list(range(1, 16)) >>> observations: list = [1, 3, 5, 7, 8, 18, 4, 1, 4, 3, 5, 7, 5, 6, 7] >>> sma: list = Stats.MovingAverage(dates, observations, 3) >>> print(sma)
-
在 VSCode 中打开 StatsPython 项目。打开statspithonraw . py 脚本。添加一个函数来测试移动平均线,包括异常处理。运行脚本,并根据需要进行调试。
八、使用 Boost.Python 和 PyBind 开发模块
介绍
在前一章中,我们看到了如何创建一个基本的 Python 扩展模块。我们添加了代码来公开底层 C++ 统计函数库的功能。我们看到了如何在PyObject
指针和本地 C++ 类型之间进行转换。虽然并不特别困难,但我们发现它很容易出错。在本章中,我们考虑两个框架——Boost。Python 和 py bind——它们克服了这些困难,使得 Python 扩展模块的开发更加容易。我们构建了两个非常相似的包装组件,第一个基于 Boost。Python 和 PyBind 上的第二个。这里的目的是比较这两个框架。接下来,我们看一个典型的 Python 客户机,并开发一个脚本来测量扩展模块的相对性能。我们用一个简单的 Flask 应用程序来结束这一章,它演示了如何使用我们的 PyBind 模块作为(有限的)统计服务的一部分。
助推。计算机编程语言
Boost Python 库是一个连接 Python 和 C++ 的框架。它允许我们使用框架提供的类型,以非侵入的方式向 Python 公开 C++ 类、函数和对象。我们可以继续使用提供的类型在包装层中编写“常规”C++ 代码。Boost Python 库非常丰富。它支持 Python 类型到 Boost 类型的自动转换、函数重载和异常翻译等。使用 Boost。Python 允许我们在 C++ 中轻松操作 Python 对象,与我们在前一章看到的低级方法相比,简化了语法。
先决条件
除了安装 Boost(我们在这个项目中使用 Boost 1.76),我们还需要一个构建版本的库。具体来说,我们需要 Boost Python 库。助推。与大多数 Boost 库功能不同,Python 不是一个只有头文件的库,所以我们需要构建它。此外,我们需要确保当我们构建库时,Boost 的版本。Python 库与我们的目标 Python 版本一致。我们一直在使用 Python 3.8,所以我们希望下面的 Boost 库能够出现:
-
\ boost _ 1 _ 76 _ 0 \ stage \ lib \ lib boost _ python 38-VC 142-mt-GD-x32-1 _ 76 . lib
-
\ boost _ 1 _ 76 _ 0 \ stage \ lib \ lib boost _ python 38-VC 142-mt-x32-1 _ 76 . lib
-
\ boost _ 1 _ 76 _ 0 \ stage \ lib \ lib boost _ python 38-VC 142-mt-GD-x64-1 _ 76 . lib
-
\ boost _ 1 _ 76 _ 0 \ stage \ lib \ lib boost _ python 38-VC 142-mt-x64-1 _ 76 . lib
这些库的 Boost 安装和构建过程在附录 A 中有更详细的描述。
项目设置
StatsPythonBoost 项目是一个标准的 Windows DLL 项目。和以前一样,该项目引用 StatsLib 静态库。表 8-1 总结了项目设置。
表 8-1
StatsPythonBoost 的项目设置
|标签
|
财产
|
价值
|
| --- | --- | --- |
| 一般 | C++ 语言标准 | ISO C++17 标准版(/std:c++17) |
| C/C++ >常规 | 其他包含目录 | <用户\用户>\蟒蛇 3 \包含**\((* *BOOST_ROOT)**\)(解决方案目录)通用\包含 |
| 链接器>常规 | 附加库目录 | <用户\用户> \Anaconda3\libs**$(BOOST_ROOT)\stage\lib |
| 生成事件>后期生成事件 | 命令行 | (参见下文) |
从表 8-1 中可以看出,该项目设置与之前的项目类似。在这种情况下,我们没有重命名目标输出。我们把这个留给后期构建脚本(参见下文)。在附加的包含目录中,我们引用了 Python.h 和 StatsLib 项目包含目录的位置。另外,我们用$(BOOST_ROOT)
宏引用 Boost 库。类似地,在附加的库目录中,我们添加了对 Python 库和 Boost 库的引用。
和前面的项目一样,我们走捷径。我们没有在 Python 环境中安装这个库,而是简单地将输出复制到我们的 Python 项目位置( \StatsPython )。从那里,我们可以用 Python 脚本或交互方式导入库。在后期构建事件中,我们将 dll 复制到脚本目录,删除之前的版本,并将 dll 重命名为。pyd 扩展,如下:
copy /Y "$(OutDir)$(TargetName)$(TargetExt)" "$(SolutionDir)StatsPython\$(TargetName)$(TargetExt)"
del "$(SolutionDir)StatsPython\$(TargetName).pyd"
ren "$(SolutionDir)StatsPython\$(TargetName)$(TargetExt)" "$(TargetName).pyd"
有了这些设置,一切都应该没有警告或错误。
代码组织
Visual Studio Community Edition 2019 为 Windows dll 生成的项目会生成一些我们忽略的文件。我们忽略了 dllmain.cpp 文件(它包含标准 Windows dll 的入口点)。我们还忽略了文件 framework.h 和 pch.cpp (除了它包含了 pch.h ,即预编译头文件)。
在 pch.h 文件中,我们有
#define BOOST_PYTHON_STATIC_LIB
#include <boost/python.hpp>
宏指示在这个 dll 模块中,我们静态链接到 Boost Python:
\ boost _ 1 _ 76 _ 0 \ stage \ lib \ lib boost _ python 38-VC 142-mt-...-...-1_76.lib
“…”取决于特定的处理器架构,尽管在我们的例子中我们只针对 x64。第二行引入了所有的 Boost Python 头。代码的其余部分像以前一样被组织成三个主要区域:函数(functions . h/functions . CPP)、转换层(conversion . h/conversion . CPP)和模块定义。此外,对于这个项目,我们有一个包装类statisticaltests . h/statisticaltests . CPP来包装 t-test 功能。我们将依次讨论这些领域。
功能
在API
名称空间中,我们声明了两个函数:DescriptiveStatistics
和LinearRegression
。两个函数都接受相应的boost::python
参数。助推。Python 附带了一组对应于 Python 的派生对象类型:
巨蟒型助推型
-
list boost::python::list
-
dict boost::python::dict
-
tuple boost::python::tuple
-
str boost::python::str
正如我们将要看到的,这使得转换成 STL 类型变得非常简单。函数内部的代码也很简单。我们首先将参数转换成 StatsLib 可用的类型。然后我们调用底层的 C++ 函数,收集结果,并把它们翻译成 Python 可以理解的形式。提升。Python 库使这变得非常简单和灵活。清单 8-1 展示了DescriptiveStatistics
函数的实现。
-
Listing 8-1The DescriptiveStatistics wrapper function
清单 8-1 中的DescriptiveStatistics
函数看起来应该很熟悉。它遵循与前一章中的原始 Python 示例相同的结构。函数声明中的主要区别是,我们可以使用 Boost 中定义的类型来代替PyObject
指针。Python 库。在这种情况下,两个参数都作为对一个boost::python::list
的const
引用被传入。第二个参数是默认的,因为我们希望能够调用DescriptiveStatistics
,不管有没有键。输入参数分别被转换成一个std::vector<double>
和一个std::vector<std::string>
。然后在调用底层统计库函数时使用这些函数。结果包像以前一样被返回(一个std::unordered_map<std::string, double>
类型)并被转换成一个boost::python::dict
。
清单 8-2 显示了LinearRegression
函数的代码。
-
Listing 8-2The LinearRegression wrapper function
从清单 8-2 中可以看出,LinearRegression
函数遵循与前面相同的结构。该函数接收两个列表,将它们转换成相应的数据集,调用底层函数,并将结果包转换成 Python 字典。
统计测试
在API
名称空间中,我们为三个统计假设检验函数创建了一个单独的名称空间StatisticalTests
。与“原始”情况一样,这里我们最初选择将TTest
类的用法包装在一个函数中。清单 8-3 显示了汇总数据 t 检验函数。
-
Listing 8-3Wrapping up the TTest class in a function
如清单 8-3 所示,为一个类提供过程化包装的方法很简单:我们获取输入数据并创建一个TTest
类的实例(取决于函数调用和参数)。然后我们调用Perform
进行计算,调用Results
检索结果。这些然后被翻译回 Python 调用者。本例中的SummaryDataTTest
函数接受与汇总数据 t-test 的构造函数参数相对应的四个参数。参数被打成对一个boost::python::object
的const
引用。这为PyObject
提供了一个包装器。然后,该函数利用boost::python::extract<T>(val)
从参数中获取一个double
值。一般来说,语法比使用PyArg_ParseTuple
更干净、更直接。该函数的其余部分调用Perform
并检索Results
。和前面的DescriptiveStatistics
和LinearRegression
一样,它们被转换成一个boost::python::dict
并返回给调用者。
转换层
正如我们前面看到的,对于内置类型(bool
、int
、double,
等等),我们可以使用一个模板化的提取函数:
boost::python::extract<T>(val).
对于 STL 类型的转换,我们有三个inline
d 函数。首先是一个模板函数to_std_vector
。这就把代表 ?? 的 ?? 转换成了 ??。清单 8-4 显示了代码。
-
Listing 8-4Converting a boost::python::object list to a std::vector
清单 8-4 从构造一个空的std::vector
开始。然后,我们遍历输入列表,提取各个值,并将它们插入到向量中。我们使用这种基本方法来说明以标准方式访问列表元素。我们可以使用boost::python::stl_input_iterator<T>
直接从迭代器中构造结果vector<T>
。我们使用这个函数将一列double
转换成一个double
的向量,并将一列字符串键转换成一个string
的向量
第二个功能是to_dict
。这是一个专门的函数,用于将结果集转换为 Python 字典。清单 8-5 显示了代码。
-
Listing 8-5Converting the results package to a Python dictionary
在这种情况下,我们输入一个对std::unordered_map<std::string, double>
的const
引用,并通过简单地迭代结果将内容返回到一个boost::python::dict
中。最后一个功能是to_list
。这类似于前面的to_dict
功能。在这种情况下,我们创建一个 Python list
,并从矢量double
中填充它。
模块定义
我们的助力。Python 模块在 module.cpp 中定义。模块定义包括我们希望向 Python 公开的函数和类。我们将依次处理每一个问题。清单很长,所以被分成两部分。首先,清单 8-6a 显示了公开这些函数的代码。
-
Listing 8-6aThe functions: StatsPythonBoost module definition
在清单 8-6a 中,模块定义的这一部分应该看起来有些熟悉。它与我们在前一章中看到的“原始”方法没有太大的不同。我们使用boost::python::def
函数来声明我们正在包装的函数。第一个参数是我们想从 Python 调用的函数名。第二个参数是函数地址。最后一个参数是docstring
。正如前面针对DescriptiveStatistics
函数所指出的,我们希望能够在有键和无键的情况下从 Python 中调用它,并让它像下面的交互会话演示的那样运行:
>>> import StatsPythonBoost as Stats
>>> data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> results = Stats.DescriptiveStatistics(data)
>>> print(results)
{'Mean': 4.5, 'Count': 10.0, 'Kurtosis': -1.2000000000000002, 'Skew.P': 0.0, ... }
>>> keys = ['Mean', 'StdDev.P']
>>> results = Stats.DescriptiveStatistics(data, keys)
>>> print(results)
{'Mean': 4.5, 'StdDev.P': 2.8722813232690143}
为了做到这一点,我们需要两个单独的重载函数。这和我们在第三章中使用的 C++/CLI 封装方法是一样的。然而,在这种情况下,我们不需要显式编写重载。我们利用宏BOOST_PYTHON_FUNCTION_OVERLOADS
为我们生成重载。参数是生成器名称、我们想要重载的函数、最小参数数(本例中为 1)和最大参数数(本例中为 2)。定义好这个之后,我们将把f_overloads
结构和docstring
一起传递给def
函数。
清单 8-6b 中显示的模块定义的第二部分声明了可以在 Python 中直接使用的类。
-
Listing 8-6bThe classes: StatsPythonBoost module definition
清单 8-6b 显示了我们在这个模块中包装的TTest
和DataManager
类。定义了这些类后,我们可以从 Python 脚本中编写以下内容,例如:
# Perform t-test from summary data
t: Stats.TTest = Stats.TTest(5, 9.261460, 0.2278881e-01, 195)
t.Perform()
print(t.Results())
t-test 的 C++ 包装类在 StatisticalTests.h 中定义。类模板参数引用了我们的包装类。在这种情况下,我们将其命名为StudentTTest
,以区别于底层的Stats::TTest
类。这个类拥有底层Stats::TTest
类的一个实例。构造函数确定要执行的 t-test 的类型,并使用我们已经看到的相同转换在boost::python
类型和底层 C++ 类型之间进行转换。
从清单 8-6b 中的模块定义中,我们可以看到第一个参数是类名"TTest"
。这是我们将从 Python 调用的类型的名称。此外,我们定义了一个带有四个参数的init
函数(构造函数)。然后,我们定义两个额外的init
函数,每个函数用于剩余的带有相应参数的构造函数。最后,我们定义两个函数Perform
和Results
。所有的功能都提供了一个docstring
。这就是我们向 Python 公开原生 C++ 类型所需做的全部工作。
DataManager
类以类似的方式公开。C++ 包装类在名称空间API::Data
中的 DataManager.h 中定义。这允许我们将包装类与同名的 StatsLib C++ 类分开。和以前一样,包装类的目的是处理类型转换和管理 StatsLib 中底层DataManager
类的生命周期。清单 8-7 显示了一个典型的示例函数。
-
Listing 8-7The DataManager::ListDataSets function
从清单 8-7 中,我们可以看到函数ListDataSets
使用 Boost 返回一个 Python list
。Python 类型。该列表包含被键入为的Stats::DataSetInfo
项
using DataSetInfo = std::pair<std::string, std::size_t>;
这些项目包含数据集名称和数据中的观测值数量。该函数首先从该类包装的成员m_manager
中获取当前加载的数据集。在 for 循环中,我们使用函数boost::python::make_tuple
创建一个包含数据集信息的 Python tuple
元素。然后将它追加到结果列表中,并返回给调用者。其余的功能同样简单明了。
异常处理
和前一章一样,异常应该从包装函数中处理。特别是,我们关心错误的参数,所以我们应该检查类型并适当地报告异常。我们可以使用与上一章相同的方法(手动将 C++ 异常转换成 Python 异常)。但是,我们也可以利用 Boost.Python。Python 框架将我们的函数包装在对.def(...)
的调用中,这样就不会通过 Python 直接调用它们。而是 Python 调用function_call(...)
(\ boost _ 1 _ 76 _ 0 \ libs \ Python \ src \ object \ function . CPP)。这个函数将实际的函数调用包装在一个异常处理程序中。异常处理程序以我们之前的方式(\ boost _ 1 _ 76 _ 0 \ libs \ python \ src \ errors . CPP)处理异常,尽管它捕获并转换更多的异常类型。这意味着 Python 不会暂停,异常会得到很好的处理。我们可以使用下面的 Python 代码对此进行测试,该代码在list
中传递一个字符串,而不是预期的数字项:
try:
x = [1, 3, 5, 'f', 7]
summary: dict = Stats.DescriptiveStatistics(x)
print(summary)
except Exception as inst:
report_exception(inst)
报告的错误是
<class 'TypeError'>
No registered converter was able to produce a C++ rvalue of type double from this Python object of type str
此错误由 Boost 提供。另一方面,如果我们传入一个空数据集,我们会得到以下结果:
try:
x = []
summary: dict = Stats.DescriptiveStatistics(x)
print(summary)
except Exception as inst:
report_exception(inst)
报告的错误是
<class 'ValueError'> The data is empty.
这是从基础 StatsLib 中引发的错误。基本上,我们在前一章写的错误处理现在是免费提供的。
皮巴弟
在本节中,我们将开发第三个也是最后一个 Python 扩展模块。这次我们使用 PyBind。助推。Python 已经存在很长时间了,它所属的 Boost 库提供了广泛的功能。如果我们想要做的只是创建 Python 扩展模块,这就使得它成为一个相对重量级的解决方案。PyBind 是一个轻量级的替代方案。它是一个只有头文件的库,提供了大量的函数来帮助编写 Python 的 C++ 扩展模块。PyBind 可从这里获得: https://github.com/pybind/pybind11
。
先决条件
本节的唯一先决条件是将 PyBind 安装到 Python 环境中。您可以在命令提示符下使用pip install pybind
。或者可以下载转轮( https://pypi.org/project/pybind11/#files
)运行pip install "pybind11-2.7.0-py2.py3-none-any.whl"
。
项目设置
StatsPythonPyBind 项目的设置方式与前一个项目类似。这是一个标准的 Windows DLL 项目。表 8-2 总结了项目设置。
表 8-2
StatsPythonPyBind 的项目设置
|标签
|
财产
|
价值
|
| --- | --- | --- |
| 一般 | C++ 语言标准 | ISO C++17 标准版(/std:c++17) |
| C/C++ >常规 | 其他包含目录 | <用户\用户>\蟒蛇 3 \包含<用户>\ AppData \ Roaming \ Python \ Python 37 \ site-packages \ pybind 11 \ include\((解决方案目录)通用\包含* |
| 链接器>常规 | 附加库目录 | *<用户\用户> \Anaconda3\libs**\)(BOOST_ROOT)\stage\lib |
| 生成事件>后期生成事件 | 命令行 | (参见下文) |
我们像以前一样创建一个模块,复制到脚本目录并重命名为。pyd 。我们使用以下脚本:
del "$(SolutionDir)StatsPython\$(TargetName).pyd"
copy /Y "$(OutDir)$(TargetName)$(TargetExt)" "$(SolutionDir)StatsPython\$(TargetName)$(TargetExt)"
此外,我们已经删除了 pch 文件,并将项目设置为不使用预编译头文件。最后,我们在项目引用中添加了对 StatsLib 项目的引用。在这一点上,所有的构建都应该没有警告或错误。
代码组织:module.cpp
在这个项目中,只有一个文件, module.cpp 。这个文件包含所有的代码。正如我们在上一节 Boost 中看到的那样。Python 和上一章一样,我们通常将转换层从包装的函数和类中分离出来。我们已经将这些从模块定义中分离出来。这是一种在包装层组织代码的便捷方式,并允许我们适当地分离关注点(如转换类型或调用函数)。然而,PyBind 简化了这两个方面。
在文件 module.cpp 的顶部,我们包含了 PyBind 头:
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
接下来是我们的 StatsLib includes。
以前,我们必须声明接受 Python 类型作为参数(或者是PyObject
或者是boost::python::object
)的包装器/代理函数,并将它们转换成底层的原生 C++ 类型。使用 PyBind,我们不需要这样做。我们现在有了一个定义模块的宏PYBIND11_MODULE
。清单很长,所以我们把它分成三个部分。第一部分处理我们公开的函数,接下来的两部分处理我们公开的类。我们公开的函数如清单 8-8a 所示。
-
Listing 8-8aThe function definitions in the StatsPythonPyBind module
PYBIND11_MODULE
宏定义了 Python 在导入语句中使用的模块名statspithonpybind。在模块定义中,我们可以看到DescriptiveStatistics
和LinearRegression
函数的声明。.def(...)
函数用于定义导出的函数。就像之前一样,我们给它一个从 Python 调用的名字,最后一个参数是一个docstring
。
然而,与以前不同,我们不需要单独的包装函数。我们可以简单地提供底层函数地址。这是第二个参数。参数和返回类型的转换由 PyBind 框架处理。对于有第二个默认参数的Stats::GetDescriptiveStatistics
函数,我们可以提供关于参数结构的更多信息。具体来说,PyBind 允许我们指定参数和缺省值(如果需要的话),所以我们在函数地址后添加参数,py::arg("data")
和py::arg("keys")
缺省为所需的值。接下来,三个功能SummaryDataTTest
、OneSampleTTest
和TwoSampleTTest
现在完全没有必要了。我们提供的包装器仅用于说明。双样本 t-test 包装器的代码如下:
std::unordered_map<std::string, double> TwoSampleTTest(const std::vector<double>& x1, const std::vector<double>& x2)
{
Stats::TTest test(x1, x2);
test.Perform();
return test.Results();
}
这里重要的不是函数如何包装TTest
类,而是包装函数使用原生 C++ 和 STL 类型作为函数参数和返回值。使用 Boost。Python,我们将不得不从/转换到boost::python::object
。但是这里我们不再需要从 Python 类型转换到 C++ 类型。当然,如果我们愿意,我们可以显式包装函数。这是一个设计选择。
模块定义的第二部分处理类定义。清单 8-8b 中显示了TTest
类。
-
Listing 8-8bThe description of the TTest class exported to Python
清单 8-8b 展示了底层 C++ StatsLib 中的TTest
类是如何暴露给 Python 的。就像 Boost 的情况一样。Python,我们描述了我们想要使用的类型“TTest
”。但是,在这种情况下,py::class_
对象的模板参数是底层的Stats::TTest
类。引用的类不是包装类,就像 Boost.Python 一样。在模板实参和参数传递给py::class_
的构造函数后,我们使用.def
函数来描述类的结构。在这种情况下,我们声明三个TTest
构造函数,它们各自的参数作为模板参数传递给py::init<>
函数。同样,值得强调的是,我们不需要做任何转换;我们只是传入原生 C++ 类型和 STL 类型(而不是boost::python::object
类型)。最后,我们声明函数Perform
和Results
,以及一个匿名函数,将对象的字符串表示返回给 Python。
DataManager
类的定义同样简单。清单 8-8c 显示了类定义。
-
Listing 8-8cThe DataManager class definition
从清单 8-8c 中我们可以看到,我们在.def
函数中需要做的就是提供从 Python 使用的函数名到底层 C++ 函数的映射。除了在DataManager
类中可用的函数之外,我们还可以访问构成 Python 类定义一部分的函数。例如,DataManager
用一个定制的to_string
函数扩展了__repr__
函数,该函数输出关于数据集的内部信息。
正如我们在这个项目中看到的,包装器和“转换”层都是最小的。PyBind 提供了广泛的工具,允许我们轻松地将 C++ 代码连接到 Python。在这一章中,我们仅仅触及了表面。有大量的特性,我们只介绍了其中的一小部分。此外,我们意识到我们实际上只为最“普通”的情况编写了代码(利用了 PyBind 允许我们轻松做到这一点的事实)。
然而,虽然使用 PyBind 使 C++ 类和函数的公开变得简单明了,但我们需要意识到在幕后还有很多事情要做。特别是,我们需要知道可以传递给module_::def()
和class_::def()
函数的返回值策略。这些注释允许我们为返回非平凡类型的函数调整内存管理。在这个项目中,我们只使用了默认策略return_value_policy::automatic
。对这个主题的全面讨论超出了本章的范围。但是,正如文档所指出的,返回值策略是很复杂的,正确处理它们很重要。 1
如果我们后退一步,我们可以看到,在模块定义方面,两者都有所提升。Python 和 PyBind 为我们提供了定义 Python 实体的元语言。这似乎是一条复杂的路。可以说,用原生 Python 编写等价类比用元语言描述 C++ 类要容易一些。然而,我们在这里采用的描述原生 C++ 类的方法,显然解决了一个不同的问题,也就是说,它提供了一种(相对)简单的方法来导出 C++ 中的类,并在 Python 环境中以预期的方式管理它们。
除了定义函数和类,我们还小心地添加了文档字符串。这很有用,如果我们打印出这个类的帮助,就可以看到这些信息。这在清单 8-9 中显示了 StatsPythonPyBind 模块。
-
Listing 8-9Output from the Python help function for the TTest class
清单 8-9 显示了使用内置help()
函数的 StatsPythonPyBind 模块的输出。我们可以看到,它提供了对类方法和类初始化的描述,以及我们提供的docstring
。它还提供了有关使用的参数类型和返回类型的详细信息。我们可以非常清楚地看到声明性 C++ 类描述是如何被翻译成 Python 实体的。StatsPythonBoost 的输出是相似的,尽管不完全相同,但值得比较。作为帮助功能的替代,我们可以使用inspect
模块对我们的 Python 扩展进行自省。inspect
模块提供了额外的有用功能来帮助获取关于对象的信息。如果您需要显示详细的回溯,这可能会很有用。正如所料,我们可以从模块中检索所有信息,当然,除了源代码。这两种方法都是为了说明,用有限的 C++ 代码,我们已经开发了一个合适的 Python 对象。
异常处理
正如所料,PyBind 框架提供了对异常处理的支持。C++ 异常,std::exception
及其子类被翻译成相应的 Python 异常,并且可以在脚本中或由 Python 运行时处理。使用我们之前使用的两个示例中的第一个,来自 Python 的异常报告如下:
<class 'TypeError'>
DescriptiveStatistics(): incompatible function arguments. The following argument types are supported:
1\. (arg0: List[float]) -> Dict[str, float] Invoked with: [1, 3, 5, 'f', 7]
异常处理提供了足够的信息来确定问题的原因,并且处理可以适当地进行。值得指出的是,PyBind 的异常处理能力超越了简单的 C++ 异常翻译。PyBind 提供了对几个特定 Python 异常的支持。它还支持注册自定义异常处理程序。PyBind 文档中介绍了详细信息。
Python“客户端”
既然我们已经构建了一个可以工作的 PyBind 模块,那么尝试一下其中的一些功能就很好了。我们当然可以创建一个全功能的 Python 应用程序。但是我们更喜欢保持简单和专注。和以前一样,我们不仅关注底层功能的实现,还关注与其他 Python 组件的互操作。与前几章不同,我们没有使用(几个)Python 测试框架中的一个来编写专门的单元测试。相反,我们使用一个简单的 Python 脚本 StatsPython.py ,它扩展了我们在前一章中使用的基本脚本。我们使用别名Stats
作为一种简单的权宜之计:
import StatsPythonPyBind as Stats
#import StatsPythonBoost as Stats
这使得我们可以轻松地在增强模式之间切换。Python 扩展模块和 PyBind 扩展模块。这并不是作为一种通用方法提出的,它只是为了方便测试这里的函数和类。
脚本本身定义了执行底层 StatsLib 功能的函数。例如,它还允许我们对TTest
类进行简单的并行测试。清单 8-10 显示了功能run_statistical_tests2
。
-
Listing 8-10A simple function to compare the results from two t-tests
在清单 8-10 中,该函数将两个熊猫数据框对象(从 csv 文件加载的简单数据集)作为输入,并将它们转换为list
s,这是我们的 Python 接口 StatsLib 所期望的类型。第一个调用使用过程接口。第二个相同的调用构造了我们声明的TTest
类的一个实例,并调用函数Perform
和Results
。毫不奇怪,这两种方法产生了相同的结果。
表演
尝试连接 C++ 和 Python 的原因之一是 C++ 代码可能带来的性能提升。为此,我们编写了一个小脚本, PerformanceTest.py 。我们想测试均值和(样本)标准差函数的性能。我们希望对 500,000 个项目的 Python vs. PyBind computing Mean
和StdDev
也这样做。
从 Python 的角度来看,我们有两种方法。首先,我们定义函数mean
、variance,
和stddev
。这些实现只使用基本的 Python 功能。我们还定义了相同的函数,这次使用的是 Python 统计库。这允许我们有两个不同的基线。
从 C++ 方面来说,我们对 PyBind 模块定义做了一个小的调整,这样我们就可以从 StatsLib 中公开函数Mean
和StandardDeviation
。对于Mean
函数来说,这很容易做到。这些函数存在于Stats::DescriptiveStatistics
名称空间中,并在静态库中定义。使用 PyBind 包装器 StatsPythonPyBind,我们需要做的就是将清单 8-11 中所示的描述添加到模块定义中。
-
Listing 8-11Enhancing the module definition with additional C++ functions
在清单 8-11 中,我们添加了函数"Mean"
,提供了 C++ 实现的地址,并添加了文档字符串。
StandardDeviation
函数稍微复杂一些。底层 C++ 函数有两个参数,一个是std::vector<double>
,另一个是VarianceType
的枚举。如果我们只是将函数地址传递给模块定义,我们将从 Python 得到一个运行时错误,因为函数需要两个参数。为了解决这个问题,我们需要扩展代码。此时我们有一个选择。我们可以编写一个小的包装函数来提供硬编码的VarianceType
参数,或者我们可以公开VarianceType
枚举。我们将研究这两种方法。
首先,我们来看看如何编写一个小的包装函数。清单 8-12 展示了这种方法。
-
Listing 8-12Wrapper for the underlying StandardDeviation function
用硬编码的参数包装函数不太理想,但很简单。在模块定义中,我们添加了清单 8-13 中所示的声明。
-
Listing 8-13Definition of the SampleStandardDeviation wrapper function
在清单 8-13 中,我们使用名称“StdDevS
”来反映我们请求样本标准偏差的事实。现在我们可以在性能测试中使用这个函数。
编写包装函数的另一种方法是向 Python 公开VarianceType
枚举。如果我们这样做,那么我们可以如下调用函数:
>>> data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> Stats.StdDev(data, Stats.VarianceType.Sample)
3.0276503540974917
>>> Stats.StdDev(data, Stats.VarianceType.Population)
2.8722813232690143
为了在代码中实现这一点,我们需要做两个小的改动。首先,我们描述模块中的枚举。这显示在清单 8-14 中。
-
Listing 8-14Defining the enumeration for VarianceType
在清单 8-14 中,我们使用 PyBind py::enum_
类来定义枚举VarianceType
并给它命名。注意,在这种情况下,我们已经将enum
附加到了模块上下文(py::enum_
函数中的m
参数),因为它不是类的一部分。然后,我们为相应的值添加适当的字符串。PyBind 文档中给出了对py::enum_
更详细的描述。我们还需要对函数在模块中的定义方式做一个小小的修改,以反映它需要两个参数的事实。这显示在清单 8-15 中。
-
Listing 8-15Defining additional arguments for the StdDev function
在清单 8-15 中,我们在函数定义中添加了两个py::arg
结构。这类似于我们处理GetDescriptiveStatistics
函数的第二个可选参数的方式。代码编译时没有警告或错误。我们可以使用 Python 交互式 shell 测试它是否按预期工作,如下所示:
>>> Stats.VarianceType.__members__
{'Sample': <VarianceType.Sample: 0>, 'Population': <VarianceType.Population: 1>}
有了这些修改,我们就可以回到性能测试了。 PerformanceTest.py 脚本很简单。我们导入所需的库,包括 StatsPythonPyBind。我们在 Python 中定义了两个版本的mean
和stddev
。一个版本不使用统计库,第二个版本使用。这只是方便了 Python 函数和我们的库函数之间的比较。我们添加了一个简单的测试函数,它使用随机数据并返回带有计时信息的mean
和stddev
。
这些是我们获得的结果(运行纯 Python 函数,而不是 Python 统计库,我们有理由期望后者执行得更快):
Running benchmarks with COUNT = 500000
[mean(x)] (Python implementation) took 0.005 seconds
[stdev(x)] (Python implementation) took 3.003 seconds
[Mean(x)] (C++ implementation) took 0.182 seconds
[StdDevS(x)] (C++ implementation) took 0.183 seconds
Python 函数mean(x)
比原生 C++ 函数快大约两个数量级。将 C++ 代码改为使用for-loop
而不是std::accumulate
并没有明显的区别。调查 C++ 端的延迟是由于转换层还是简单的不必要的向量复制,这可能是很有趣的。然而,原生 C++ StdDev
函数比任何一种 Python 变体都要快得多。
统计服务
在 StatsPython 项目中,有一个启动小 Flask 应用程序的脚本 StatsService.py 。Flask 应用程序是一个 web 服务的简单演示。它非常有限,仅允许用户计算汇总数据 t-test。主页面如图 8-1 所示。
图 8-1
统计服务主页
主页包含一个简单的表单,允许用户输入汇总数据 t-test 的参数。按下提交按钮后,我们计算需要的值并返回,如图 8-2 所示。
图 8-2
汇总数据 t 检验的结果
例如,要运行服务,请在 VSCode 中打开 StatsPython 项目。在终端中,键入
> py .\StatsService.py
这将在端口 5000 上启动 Flask 服务。在你的浏览器地址栏中,进入 http://localhost:5000/ 。这指向摘要数据 T-Test 页面,这是该应用程序的主页。填写所需的详细信息,然后按提交。使用来自 StatsPythonPyBind 模块的底层TTest
类,结果按预期返回。
除了启动和运行所需的少量代码之外,值得强调的是我们在多语言开发基础设施方面取得的成就。我们已经有了一个基础设施,允许我们开发和修改本机 C++ 代码,将其构建到一个库中,将该库合并到一个 Python 模块中,并在 Python web 服务中使用该功能。这种灵活性在开发软件系统时很有价值。
摘要
在本章中,我们已经使用 Boost 提供的框架构建了 Python 模块。Python 和 PyBind。这两个模块以相似的方式展示了底层统计函数库的功能。我们已经看到这两个框架在类型转换和错误处理方面都为我们做了大量的工作。此外,这两个框架都允许我们向 Python 公开原生 C++ 类。在本章的最后,我们对比了底层 C++ 函数调用和 Python 函数调用的性能。性能增强的潜力是将 C++ 连接到 Python 的一个明显原因。然而,将 C++ 连接到 Python 的一个同样令人信服的原因(如果不是更多的话)是,它让我们可以访问各种不同的 Python 库,涵盖了从机器学习(例如 NumPy 和 Pandas)到 web 服务(例如 Django 和 Flask)等等。正如我们所见,在开发松散耦合的软件系统时,能够以最小的努力向 Python 公开用 C++ 编写的功能为您提供了一个有用的额外架构选择。
额外资源
下面的链接提供了本章所涉及主题的更深入的内容。
-
Boost 的主要参考。Python 是优秀的 Boost 文档
www.boost.org/doc/libs/1_77_0/libs/python/doc/html/index.html
和参考手册www.boost.org/doc/libs/1_77_0/libs/python/doc/html/reference/index.html
。还有一个很有用的教程覆盖暴露类:www.boost.org/doc/libs/1_77_0/libs/python/doc/html/tutorial/tutorial/exposing.html
。 -
在
https://pybind11.readthedocs.io/en/latest/
的优秀 PyBind 文档中有很多有用的信息。
练习
本节中的练习处理公开与前面相同的功能,但这次是通过 Boost。Python 模块和 PyBind 模块。
以下练习使用了 StatsPythonBoost 项目:
1)在 StatsPythonBoost 中,为 z-test 函数添加过程包装器。这些函数应该与 t-test 函数几乎相同。不需要额外的转换功能。
-
在 StatisticalTests.h 中,添加以下三个函数的声明:
boost::python::dict SummaryDataZTest(const boost::python::object& mu0, const boost::python::object& mean, const boost::python::object& sd, const boost::python::object& n); boost::python::dict OneSampleZTest(const boost::python::object& mu0, const boost::python::list& x1); boost::python::dict TwoSampleZTest(const boost::python::list& x1, const boost::python::list& x2);
-
在 StatisticalTests.cpp 中,添加这些函数的实现。遵循 t-test 包装函数的代码。
-
在 module.cpp 中,向模块
BOOST_PYTHON_MODULE(StatsPythonBoost) {}
添加三个新函数 -
重新构建 StatsPythonBoost 后,在 VSCode 中打开 StatsPython 项目。打开 StatsPython.py 脚本。添加函数以使用我们之前使用的数据测试 z 测试函数。例如,我们可以添加以下函数:
def one_sample_ztest() -> None: """ Perform a one-sample z-test """ try: data: list = [3, 7, 11, 0, 7, 0, 4, 5, 6, 2] results = Stats.OneSampleZTest(3.0, data) print_results(results, "One-sample z-test.") except Exception as inst: report_exception(inst)
2)在 StatsPythonBoost 项目中,添加一个MovingAverage
函数。
-
在 Functions.h 中添加以下声明:
boost::python::list MovingAverage(const boost::python::list& dates, const boost::python::list& observations, const boost::python::object& window);
-
在 Functions.cpp 中:
-
将
#include "TimeSeries.h"
添加到文件的顶部。 -
添加实现:该函数接受三个非可选参数:一个日期列表、一个观察值列表和一个窗口大小。
-
使用现有的转换函数转换输入,并将它们传递给
TimeSeries
类的构造函数。 -
使用
Conversion::to_list
函数返回结果。
-
-
在 module.cpp 中,添加新函数:
def("MovingAverage", API::MovingAverage, "Compute a simple moving average of size = window.");
-
构建 StatsPythonBoost。它的构建应该没有警告和错误。您应该能够交互地测试
MovingAverage
函数,修改我们之前使用的脚本。 -
在 VSCode 中打开 StatsPython 项目。打开 StatsPython.py 脚本。添加一个函数来测试移动平均线,包括异常处理。运行脚本,并根据需要进行调试。
3)在 StatsPythonBoost 项目中,添加一个TimeSeries
类,该类包装了原生 C++ TimeSeries
类并计算一个简单的移动平均值。
所需的步骤如下:
-
向项目添加一个 TimeSeries.h 和一个 TimeSeries.cpp 文件。它们将分别包含包装类的定义和实现。
-
在 TimeSeries.h 中,添加类声明。例如:
namespace API { namespace TS { // TimeSeries wrapper class class TimeSeries final { public: // Constructor, destructor, assignment operator and MovingAverage function private: Stats::TimeSeries m_ts; }; } }
-
在 TimeSeries.cpp 中,添加类实现。构造函数将
boost::python::list
参数转换成合适的std::vector
类型。MovingAverage
函数提取窗口大小参数,并将调用转发给m_ts
成员。使用Conversion::to_list()
函数返回结果。 -
在 module.cpp 中,添加包含文件,并将类声明添加到
BOOST_PYTHON_MODULE(StatsPythonBoost)
中,如下:// Declare the TimeSeries class class_<API::TS::TimeSeries>("TimeSeries", init<const list&, const list&>("Construct a time series from a vector of dates and observations.")) .def("MovingAverage", &API::TS::TimeSeries::MovingAverage, "Compute a simple moving average of size = window.") ;
-
重新构建 StatsPythonBoost 后,在 VSCode 中打开 StatsPython 项目。打开 StatsPython.py 脚本。添加一个函数来测试移动平均线,包括异常处理。运行脚本,必要时进行调试。
以下练习使用 StatsPythonPyBind 项目:
4)为 z 测试函数添加过程包装器。这些函数应该与 t-test 函数几乎相同。不需要额外的转换功能。
-
在 module.cpp 中,添加三个函数的声明/定义。
-
在模块定义中,为这三个函数添加条目。遵循 t-test 包装函数的代码。
-
在重新构建 StatsPythonPyBind 项目后,在 VSCode 中打开 StatsPython 项目。打开 StatsPython.py 脚本。添加函数以使用我们之前使用的数据测试 z 测试函数。
5)给PYBIND11_MODULE
添加一个新的类别ZTest
。例如,遵循TTest
类的定义:
py::class_<Stats::ZTest>(m, "ZTest")
.def(py::init<double, double, double, double>(), "...")
.def(py::init<double, const std::vector<double>& >(), "...")
.def(py::init<const std::vector<double>&, const std::vector<double>& >(), "...")
.def("Perform", &Stats::ZTest::Perform, "...")
.def("Results", &Stats::ZTest::Results, "...")
.def("__repr__", [](const Stats::ZTest& a) {
return "<example.ZTest>";
}
);
注意,在这种情况下,不需要单独的包装器。我们可以简单地引用底层的原生 C++ 类。
- 在重新构建 StatsPythonPyBind 项目后,在 VSCode 中打开 StatsPython 项目。打开 StatsPython.py 脚本。添加函数以使用我们之前使用的数据测试 z 测试函数。我们可以扩展之前用于测试单样本 z 测试的函数,以测试过程包装器和类,如下所示:
def one_sample_ztest() -> None:
""" Perform a one-sample z-test """
try:
data: list = [3, 7, 11, 0, 7, 0, 4, 5, 6, 2]
results = Stats.OneSampleZTest(3.0, data)
print_results(results, "One-sample z-test.")
z: Stats.ZTest = Stats.ZTest(3.0, data)
z.Perform()
print_results(z.Results(), "One-sample z-test.(class)")
except Exception as inst:
report_exception(inst)
两次调用的结果输出应该是相同的。
6)在 StatsPythonPyBind 项目中,添加一个MovingAverage
函数。
-
在 module.cpp 中,增加
#include "TimeSeries.h"
。 -
在 module.cpp 中,添加包装函数的声明/定义。
-
在 module.cpp 中,将
MovingAverage
函数的定义添加到PYBIND11_MODULE
公开的函数列表中。 -
在重新构建 StatsPythonPyBind 项目后,在 VSCode 中打开 StatsPython 项目。打开 StatsPython.py 脚本。添加一个函数来测试移动平均线,包括异常处理。运行脚本,必要时进行调试。
std::vector<double> MovingAverage(const std::vector<long>& dates, const std::vector<double>& observations, int window)
{
Stats::TimeSeries ts(dates, observations);
const auto results = ts.MovingAverage(window);
return results;
}
7)暴露原生 C++ TimeSeries
类和简单移动平均函数。
- 在 module.cpp 中,添加包含文件,并添加类声明。该类定义将类似于我们之前添加到 StatsPythonBoost 项目中的类定义。
py::class_<Stats::TimeSeries>(m, "TimeSeries")
.def(py::init<const std::vector<long>&, const std::vector<double>&>(),
"Construct a time series from a vector of dates and observations.")
.def("MovingAverage", &Stats::TimeSeries::MovingAverage, "Compute a simple moving average of size = window.")
.def("__repr__",
[](const Stats::TimeSeries& a) {
return "<TimeSeries> containing: " + to_string(a);
}
);
为了正确地添加__repr__
方法,我们需要修改底层的类定义以允许访问内部,或者编写一个额外的to_string()
方法。这是最后一个练习。
- 在重新构建 StatsPythonPyBind 项目后,在 VSCode 中打开 StatsPython 项目。打开 StatsPython.py 脚本。添加一个函数来测试移动平均线,包括异常处理。运行脚本,必要时进行调试。
值得强调的是,与通过 CPython 或 Boost 公开包装器所需的工作量相比,使用 PyBind 公开ZTest
类和TimeSeries
类非常简单。Python 包装器。
九、总结
在本书中,我们的目标是开发将 C++ 代码库(虽然简单,但有点做作)连接到用其他语言编写的客户端软件的组件,特别是 C#、R 和 Python。目的是使 C++ 库中的功能可用,并允许从其他客户端语言访问该功能。这是我们已经完成的。在这样做的时候,我们已经涉及了相当多的领域。
我们从构建一个 C++ 统计函数库开始。这构成了我们想要向客户展示的基础。原因是这很容易理解,但比一个玩具例子更完整。在开发连接到其他语言的包装器组件时,它有足够的特性来说明现实世界中的问题。在整本书中,我们让源代码驱动我们想要公开的内容,虽然这在某种程度上限制了覆盖范围,但也更易于管理。
在这个 C++ 代码基础之上,我们构建了包装器组件,允许我们向不同的语言公开 C++ 功能。首先,我们构建了一个 C++/CLI 程序集。我们看到它在许多不同的环境中是多么容易使用。我们在一个简单的控制台客户端中测试了该功能——测试了我们调用的函数以及该组件如何与其他 C# 库(在本例中为 Accord)进行互操作。网)。我们还通过 Excel-DNA 毫不费力地将组件连接到 Excel。
在此之后,我们构建了一个 R 包,它使用 Rcpp 将 C++ 代码库连接到 R。和以前一样,我们练习了基本功能,但也看了一下如何将 StatsR 组件与其他 R 包一起使用,特别是tidyverse
、ggplot,
和benchmark
。最后,我们构建了一个闪亮的小应用程序来演示我们的组件如何与其他 R 包交互。我们看到了 StatsR 包可以在广阔的 R 宇宙中的任何地方使用。在这个过程中,我们建立了一个开发基础设施,它由一个 IDE (CodeBlocks)和一个 IDE 组成,IDE 用于使用 R 所需的编译器来构建 C++ 代码,IDE 用于编写 Rcpp 包(RStudio),我们可以使用它来开发和构建包。
最后,我们还构建了 Python 扩展模块:确切地说是三个。我们看到了使用低级 CPython 方法的潜在缺陷,然后考虑使用两种 Boost。Python 和 PyBind 作为连接 C++ 和 Python 的框架。我们看到了这两种框架如何促进包装组件的开发。我们还看到,潜在的性能提升(不能保证)只是将 C++ 连接到 Python 的众多可能原因之一。将一个新的组件引入 Python 世界,与 NumPy 和 Pandas 等库一起无缝运行,这也是非常重要的。
总的来说,我们已经了解了如何为不同的语言包装组件建立项目。我们已经花了一些时间来研究包装器的设计:将关注点分成功能层、进行调用的部分和类型转换层。我们已经研究了类型转换的细节,以及如何有效地推广它们。在这个过程中,我们触及了许多其他的软件开发主题:一些与代码相关的,比如异常处理;其他的与开发过程相关,比如测试和调试。除了简单地构建组件,我们还建立了一个简化(多语言)开发过程的工具基础设施。
另一方面,在限制我们自己公开有限的 C++ 库中的底层功能时,我们忽略了许多重要的方面。我们还没有触及原生 C++ 库中的线程和并发性,以及如何将其暴露给不同的客户端语言。从实际组件的角度来看,我们忽略了许多方面。在 C# 中,我们还没有覆盖委托;在 R 中,我们没有涉及扩展模块;在 Python 中,我们仅仅触及了 PyBind 的皮毛。所有这些都需要一本书。我们在这里提供的是未来发展的一些起点。
在更一般的层面上,目的是扩展开发软件时可用的架构选择。在第二章中,我们看到了在 Windows 应用程序中直接包含组件的一些限制(链接到组件库或 dll )。我们已经证明了一个可行的替代方案是开发可以在多种上下文(Windows 应用程序、web 应用程序)中使用的包装器组件(程序集、包和模块),并且来自不同的语言,C#、R 和 Python。这导致了一个更加松散耦合的软件系统。组件本身可以提供完全异构的服务,但是它们可以互操作,因为它们参与了底层框架,无论它是。NET 通过 C++/CLI,R 通过 Rcpp,或者 Python。