ByT5:通过预先训练的字节到字节模型走向无令牌的未来
NLP 研究论文摘要
作者图片
在这篇博客中,我试图根据我的理解,用预先训练好的字节到字节模型 来总结论文 ByT5:迈向一个无令牌的未来。请随时评论你的想法!
想法
迄今为止,大多数 NLP 研究广泛使用了记号赋予器的概念来将文本序列分成更小的词汇单元。如今,你会发现子词标记化是人们用来表示文本单元的事实上的技术,(在过去的某个时候是单字,双字)
考虑到这些方法的局限性,其中一些是—
- 处理 OOV 时不够稳健。
- 拼写、大小写等方面的变化导致了不同的表示。
作者提出的无令牌模型直接在原始文本(字节)上操作**,给我们带来了下面提到的好处——**
- 他们可以处理任何语言的文本。我们需要而不是需要特定于语言的标记器。您只需要一个标记器!】
- 它们对噪声具有很强的鲁棒性,并最大限度地减少了复杂文本预处理管道的麻烦。
- 我们现在不需要庞大的词汇矩阵作为字节级模型,根据定义,只需要 256 个嵌入。
mT5(薛等,2020)和 ByT5(本作品)的训练前实例创建和网络架构比较|图片来自来源
虽然,字节级模型的主要缺点之一是字节序列通常比原始文本序列长,导致处理成本较高。众所周知,变形金刚中的自我关注是一种二次计算,当试图处理越来越长的序列时,这种计算会带来巨大的挑战。话虽如此,我们确实有进步,如 Longformer 等,它们利用稀疏注意力和其他巧妙的技术来处理非常大的序列。
mT5 与 ByT5 —设计
- mT5/ T5 使用子字标记,而 ByT5 使用原始字节作为模型的输入,这使得不知道文本预处理的类型,等等。
- mT5/T5 使用跨度掩蔽的概念作为在大量未标记数据上预先训练模型的自我监督目标。ByT5 通过屏蔽字节使用类似的概念。此外,mT5 平均屏蔽掉 3 个子字标记,这里作者发现更长的屏蔽序列有利于模型,因此他们将其平均屏蔽跨度长度设置为 20 字节。
- mT5/T5 使用所谓的“平衡架构”*(编码器深度= =解码器深度),然而,byT5 的作者发现,当编码器深度几乎是解码器深度的 3 倍时,它工作得最好,从而使整个架构编码器很重。此外,即使在降低解码器的容量后,他们发现该模型在分类和生成(翻译/摘要)*任务上表现更好。
此外,作为质量控制协议,由于根据 UTF-8,并非所有的字节序列都是合法的,因此作者通过使用 python 的字节解码函数—. bytes . decode(" utf-8 ",errors=“ignore”) 来删除任何无效的序列
技术性能分析
- 通常,词汇表中每个标记的向量表示采用模型总参数空间中的大多数参数。例如,在最近的 mT5-Base 模型中,词汇和 softmax 输出矩阵占总参数计数的 66%。对于字节模型,由于不会出现这种情况,如果我们要补偿大的模型参数计数,我们可以通过使我们的模型更深更宽来实现它,通过拥有更复杂的模型来给我们带来优势。
- 与使用单词或子单词标记化方案相比,给定文本片段的字节序列通常更长。因此,你会有明显较高的计算成本,因为变压器使用自我关注,具有二次时间复杂度。
结果
摘要文本摘要*(英语)*
他们在 XSum 数据集上评估 mT5 和 ByT5 来进行抽象文本摘要。正如您在下表中看到的,对于所有大小的变体,ByT5 都优于 mT5,并且接近专门为抽象概括而训练的Pegasusmodel*(17.0)*。
GEM-XSUM |图片来自来源
文本分类(英语)
他们评估了 mT5 和 ByT5 在不同模型尺寸下在粘合和强力胶任务上的表现。正如我们在下表中看到的,仅在小型和基本型号中,ByT5 的性能优于 mT5。作者解释说,这可能是由于有效的参数使用,因为大多数 mT5 参数只是作为词汇表矩阵被锁定。
mT5 和 ByT5 在胶水和强力胶上不同型号的性能|图片来自来源
正如您在下表中看到的,在固定参数计数设置下,随着模型大小的增加,两个模型的 dmodel 和 dff 变得可比,这与模型大小较低时不同。这是上表所示行为的可能原因。
mT5 和 ByT5 架构的比较|图片来自来源
好了,这篇博客到此为止。论文中提到了更多的实验。我鼓励你也阅读它们。
如果你愿意,你也可以 查看我写的其他研究论文摘要 。
请随意阅读整篇论文,并向作者问好,感谢他们的贡献。
论文标题: ByT5:用预先训练好的字节到字节模型走向无令牌的未来
论文链接:https://arxiv.org/pdf/2105.13626v1.pdf
作者: 【林挺】薛,阿迪雅巴鲁阿,诺亚恒,拉米阿尔-Rfou ,莎兰纳朗,米希尔卡莱, Adam Roberts ,科林·拉斐尔
我希望你喜欢读这篇文章。如果你愿意支持我成为一名作家,可以考虑注册成为的媒体成员。每月只需 5 美元,你就可以无限制地使用 Medium。谢谢你!
字节对编码:基于子字的标记化算法
了解最新的 NLP 模型使用的基于子词的符号化算法——字节对编码(BPE)
人工智能的分支**【自然语言处理(NLP)** ,就是让机器理解和处理人类语言。对于机器来说,处理人类语言不是一件容易的事情,因为机器处理的是数字而不是文本。💻NLP 是人工智能的一个如此巨大和广泛研究的分支,我们不时听到这个领域的新进展。研究人员正在努力让机器理解人类语言及其背后的语境。
在理解人类语言的过程中,一个主要的角色是由标记化者扮演的。标记化算法可以是基于单词、子单词或字符的。每种类型的分词器帮助机器以不同的方式处理文本。两者各有优势。如果您想了解 NLP 中使用的不同类型的记号赋予器,那么您可以阅读这篇文章。这篇文章是关于 TDS 的实践教程,可以让你很好地理解这个主题。😇
这些分词器中最流行的是基于子词的分词器。大多数最新的 NLP 模型都使用这个标记器。因此,让我们首先了解什么是基于子词的记号赋予器,然后理解最新的 NLP 模型使用的字节对编码(BPE)算法。🙃
基于子词的标记化
基于子词的标记化是介于基于词和基于字符的标记化之间的一种解决方案。😎主要思想是解决基于单词的标记化(非常大的词汇量,大量的 OOV 标记,以及非常相似的单词的不同含义)和基于字符的标记化(非常长的序列和不太有意义的单个标记)所面临的问题。
基于子词的记号化算法不会将频繁使用的词分成更小的子词。而是将生僻的单词拆分成更小的有意义的子单词。例如,“男孩”不是分裂的,而是“男孩”分裂为“男孩”和“s”。这有助于模型了解单词“boys”是使用单词“boy”形成的,单词“boy”的意思略有不同,但词根相同。
一些流行的子词标记化算法是单词块、字节对编码(BPE)、Unigram 和句子块。在本文中,我们将讨论字节对编码(BPE)。BPE 用于语言模型,如 GPT-2,罗伯塔,XLM,福楼拜等。这些模型中的一些使用空间标记化作为预标记化方法,而一些使用由 Moses、spaCY、ftfy 提供的更高级的预标记化方法。那么,我们开始吧。🏃
字节对编码(BPE)
BPE 是一种简单的数据压缩算法,在这种算法中,最常见的一对连续数据字节被替换为该数据中不存在的字节。在 1994 年发表的文章“一种新的数据压缩算法中首次对其进行了描述。下面的例子将解释 BPE,并取自维基百科。
假设我们有需要编码(压缩)的数据**。字节对 aa 出现的频率最高,因此我们将使用 Z 替换它,因为 Z 在我们的数据中没有出现。所以我们现在有了 ZabdZabac 其中 Z = aa 。下一个公共字节对是 ab ,所以让我们用 Y 来代替它。我们现在有了 ZYdZYac 其中 Z = aa 和 Y = ab 。唯一剩下的字节对是 ac ,它看起来只有一个,所以我们不会对它进行编码。我们可以使用递归字节对编码将 ZY 编码为 X 。我们的数据现在已经转换为 XdXac ,其中 **X = ZY,Y = ab,和 Z = aa 。它不能被进一步压缩,因为没有字节对出现超过一次。我们通过以相反的顺序执行替换来解压缩数据。
NLP 中使用了它的一个变体。让我们一起来了解一下它的 NLP 版本。🤗
BPE 确保最常见的单词在词汇表中被表示为单个记号,而不常见的单词被分解为两个或多个子单词记号,这与基于子单词的记号化算法所做的是一致的。
假设我们有一个语料库,其中包含单词(在基于空间的预标记化之后)——old、old、high 和 lowest,我们计算这些单词在语料库中的出现频率。假设这些词的频率如下:
{“老”:7,“老”:3,“最好”:9,“最低”:4}
让我们在每个单词的末尾添加一个特殊的结束标记“”。
{ "老< /w > ": 7,"老< /w > ": 3,"最细< /w > ": 9,"最低< /w > ": 4}
在每个单词的末尾添加“”标记来标识单词边界,以便算法知道每个单词的结束位置。这有助于算法检查每个字符,并找到频率最高的字符对。当我们将在字节对中包含“”时,我将详细解释这一部分。
接下来,我们将把每个单词拆分成字符,并计算它们出现的次数。初始标记将是所有字符和“”标记。
由于我们总共有 23 个单词,所以我们有 23 个“”记号。第二高频率的标记是“e”。我们总共有 12 种不同的代币。
BPE 算法的下一步是寻找最频繁的配对,合并它们,并一次又一次地执行相同的迭代,直到我们达到我们的令牌限制或迭代限制。
合并可以让你用最少的符号来表示语料库,这是 BPE 算法的主要目标,也就是压缩数据。为了合并,BPE 寻找最频繁出现的字节对。这里,我们认为一个字符和一个字节是一样的。这是英语中的一种情况,在其他语言中可能有所不同。现在,我们将最常见的 bye 对合并成一个标记,并将它们添加到标记列表中,并重新计算每个标记的出现频率。这意味着我们的频率计数将在每个合并步骤后改变。我们将继续进行这个合并步骤,直到达到迭代次数或达到令牌限制大小。
迭代次数
迭代 1: 我们将从第二常见的标记“e”开始。在我们的语料库中,带有“e”的最常见的字节对是“e”和“s”(在单词 finest 和 lowest 中),它们出现了 9 + 4 = 13 次。我们将它们合并形成一个新的标记“es ”,记下它的频率为 13。我们还将从单个令牌(“e”和“s”)中减少计数 13。这将让我们知道剩余的“e”或“s”令牌。我们可以看到“s”根本没有单独出现,“e”出现了 3 次。以下是更新后的表格:
迭代 2: 我们现在将合并标记“es”和“t”,因为它们在我们的语料库中出现了 13 次。因此,我们有一个频率为 13 的新令牌“est ”,我们将把“es”和“t”的频率减少 13。
迭代 3: 现在让我们使用“< /w >”令牌。我们看到字节对“est”和“< /w >”在我们的语料库中出现了 13 次。
**注意:**合并停止令牌“< /w >”非常重要。这有助于算法理解“估计”和“最高”这样的词之间的区别。这两个单词都有“est”这个词,但是一个在末尾有一个“est”标记,一个在开头。因此,像“est”和“est < /w >”这样的标记将被不同地处理。如果算法将看到标记“est < /w >”,它将知道这是单词“最高”的标记,而不是单词“房地产”的标记。
迭代 4: 查看其他标记,我们看到字节对“o”和“l”在我们的语料库中出现了 7 + 3 = 10 次。
迭代 5: 我们现在看到字节对“ol”和“d”在我们的语料库中出现了 10 次。
如果我们现在查看我们的表格,我们会看到“f”、“I”和“n”的频率是 9,但我们只有一个包含这些字符的单词,所以我们没有合并它们。为了本文的简单起见,现在让我们停止迭代,仔细看看我们的令牌。
频率计数为 0 的令牌已从表中删除。我们现在可以看到,令牌总数是 11,比我们最初的 12 少。这是一个小语料库,但在实践中,大小减少了很多。这 11 个单词的列表将作为我们的词汇表。
您一定也注意到了,当我们添加一个令牌时,我们的计数要么增加,要么减少,要么保持不变。实际上,令牌计数先增加后减少。停止标准可以是令牌计数或迭代次数。我们选择这个停止标准,以便我们的数据集可以以最有效的方式分解成记号。
编码和解码
现在让我们看看我们将如何解码我们的例子。为了解码,我们必须简单地将所有的标记连接在一起以得到整个单词。例如,编码序列[“the 、” high “、” est 、" range 、" in 、" Seattle],我们将被解码为[“the “、” high “、” range “、” in “、” Seattle”],而不是[“the “、” high “、” enever “、” in “、” Seattle”]。请注意“est”中出现了“”标记。
对新数据进行编码的过程也很简单。然而,编码本身在计算上是昂贵的。假设单词的顺序是[“最”、“最高、“范围”、“在、“西雅图”]。我们将遍历我们在语料库中找到的所有标记——从最长到最短,并尝试使用这些标记替换给定单词序列中的子字符串。最终,我们将遍历所有的令牌,我们的子字符串将被替换为令牌列表中已经存在的令牌。如果还剩下一些子字符串(对于我们的模型在训练中没有看到的单词),我们将用未知的标记来替换它们。
一般来说,词汇量很大,但仍然有可能是一个未知单词。在实践中,我们将预先标记的单词保存在字典中。对于未知(新)单词,我们应用上述编码方法来标记新单词,并将新单词的标记添加到我们的词典中以供将来参考。这有助于我们为将来积累更丰富的词汇。
**不是贪心吗?**🤔
为了以最有效的方式表示语料库,BPE 通过查看其频率,在每次迭代中检查每个潜在的合并选项。所以,是的,它遵循一个贪婪的方法来优化最佳可能的解决方案。
不管怎样,BPE 是使用最广泛的子分词算法之一,尽管它很贪婪,但它有很好的性能。💃
我希望这篇文章能帮助你理解 BPE 算法背后的思想和逻辑。😍
参考文献:
- 【https://aclanthology.org/P16-1162.pdf
- https://huggingface.co/transformers/tokenizer_summary.html
- https://www . drdobbs . com/a-new-algorithm-for-data-compression/184402829
- https://en.wikipedia.org/wiki/Byte_pair_encoding
感谢大家阅读这篇文章。请分享您宝贵的反馈或建议。快乐阅读!📗 🖌
字节字符串、Unicode 字符串、原始字符串 Python 中所有字符串的指南
区别,用法,Python 对 NumPy 对熊猫?
在 Unsplash 上由 Hitesh Choudhary 拍摄的照片
Python 中的“字符串”?听起来像是每个 Python 程序员在他们的第一个 Python 教程中应该已经掌握的最基本的主题。然而,你知道在原始 Python 中至少有四种类型的字符串吗?你知道你的字符串实际上是如何在 Numpy 或 Pandas 或任何其他包中表示的吗?我需要知道哪些区别和注意事项?(见下文)
在这里,让我根据自己的学习经历,试着为你澄清一些困惑。我们将讨论以下主题:
- 「编码」和「解码」是什么概念?
- 什么是 raw®字符串或 format(f)字符串,何时应该使用它们?
- Numpy/Pandas 字符串和原始 Python 字符串有什么区别?
字节字符串和 Unicode 字符串(默认的 Python3 字符串)——都是关于编码的
要理解字节串和 Unicode 串的区别,我们首先需要知道什么是“编码”和“解码”。
编码和解码(图片由作者提供)
为了在计算机上存储人类可读的字符,我们需要将它们编码成字节。相比之下,我们需要将字节解码成人类可读的字符来表示。在计算机科学中,字节表示 0/1 的单位,通常长度为 8。所以字符“Hi”在计算机上实际上是以“01001000 01101001”的形式存储的,消耗 2 个字节(16 位)。
定义编码过程的规则称为编码模式,常用的有“ASCII”、“UTF-8”等。现在,问题是这些编码模式看起来怎么样?
" ASCII "将每个字符转换成一个字节。因为一个字节由 8 位组成,每一位包含 0/1。“ASCII”能代表的字符总数是 2⁸=256.26 个英文字母加上一些常用字符绰绰有余。完整信息见“ASCII”表。
但是,256 个字符显然不足以存储世界上所有的字符。有鉴于此,人们设计了 Unicode 码,其中每个字符将被编码为一个“代码点”。例如,“H”将被表示为代码点“U+0048”。根据维基百科,Unicode 可以包含 144697 个字符。但是,代码点仍然不能被计算机识别,所以我们有“ UTF-8 ”或其他变体编码模式来将代码点转换为字节。“UTF-8”是指表示一个字符的最小比特长度是 8,所以你可以猜测,“ UTF-16 是指最小比特长度是 16。UTF-8 比 UTF-16 更受欢迎,所以在本文和你的大部分工作中,因为它们与旧的原始 ASCII 标准兼容(一个字符可以用一个字节表示),理解 UTF-8 就足够了。完整信息见“UTF-8”表。
了解了基本概念后,让我们来看看 Python 中一些实用的编码技巧。在 Python3 中,默认的字符串叫做 Unicode string (u string) ,你可以把它们理解为人类可读的字符。如上所述,您可以将它们编码成字节字符串(b 字符串),字节字符串可以解码回 Unicode 字符串。
u'Hi'.encode('ASCII')
> b'Hi'b'\x48\x69'.decode('ASCII')
> 'Hi'
在 Python IDE 中,通常,在打印输出时,字节字符串会使用“ASCII”自动解码,所以这就是为什么第一个结果是人类可读的(b’Hi ‘)。更多的时候,字节串应该用十六进制代码(b’\x48\x69 ')表示,可以在任何“ASCII”表中找到。
为了总结这一节,让我们看一个“UTF-8”的例子,同样每个字符的十六进制代码可以在 UTF-8 表中找到:
b'\xe0\xb0\x86'.decode('utf-8')
> 'ఆ'
原始字符串
从这种类型的字符串开始,我们只需要知道关于默认 Unicode 字符串(u string)的一件事——反斜杠(" \ ")是 Unicode 字符串中的一个特殊字符,这样后面的字符将具有特殊的含义(即\t,\n 等)。所以为了忽略反斜杠的特殊含义,我们有了 Raw string (r string) ,其中反斜杠只是一个反斜杠,它不会对改变其后面字符的含义产生影响。
Unicode 和原始字符串(图片由作者提供)
下面是我个人的建议,除非在需要定义正则表达式匹配模式的场景下(见下面的例子),我建议使用带转义的 Unicode 字符串(使用反斜杠忽略特殊字符)。如第三个示例所示,我们使用反斜杠来确保我们输出的是文字“\”而不是新的制表符“\t”。
为什么我会推荐这个?这是因为原始字符串不能真正解决所有问题,例如,如何在原始字符串中输出文字单引号?
r'ttt'g''File "<ipython-input-76-2839752ff4e6>", line 1
r'ttt'g''
^
SyntaxError: invalid syntax
然而,将转义思想与 Unicode 字符串一起使用似乎是一种更通用的方法:
u'ttt\'g\''> "ttt'g'"
Raw string (r string)唯一有用的地方是当你处理正则表达式的时候。正则表达式是一个复杂的问题,我不打算在本文中讨论它。但是当使用正则表达式时,我们通常需要首先定义一个匹配的模式,其中推荐原始字符串。
import re
pat = re.compile(r'ENSG\d+$')
string = 'ENSG00000555'
re.search(pat,string)<_sre.SRE_Match object; span=(0, 12), match='ENSG00000555'>
格式字符串
对于有经验的 Python 程序员来说,格式字符串应该不是一个陌生的概念,它允许你动态配置我们想要打印的字符串。在 Python 版之前,创建格式字符串的推荐方法如下:
var = 'hello'
print('{} world'.format(var))> hello world
从 Python 3.5 和更高版本开始,有一个新的" f 字符串来帮助我们实现同样的目标:
var = 'hello'
print(f'{var} world')> hello world
这里我要注意的重要一点是,当使用格式字符串时,花括号“{}”变成了一个非常特殊的字符,包含了它独特的含义。因此,如果我们的目标仍然是输出文字“{}”,我们需要使用双花括号“{ { } }”对其进行转义:
'{{}}{}'.format(5)> '{}5'
此外,还要注意格式字符串中的\1
(或反斜杠后的其他数字):
# Make sure to write the command in a python file and execute the python file
'ttt {} \1'.format('rr')
> ttt rr
'ttt {} \\1'.format('rr')
> ttt rr \1
请注意,在上面的代码中,输出是通过运行 python 文件生成的,如果您使用交互式 Python 控制台,这将会令人困惑,因为输出实际上会自动编码为字节:
# if using interactive python console
'ttt {} \1'.format('rr')
> 'ttt rr \x01'
'ttt {} \\1'.format('rr')
> 'ttt rr \\1'
最后但同样重要的是,可以使用rf
string,其中我们希望为正则表达式构造一个原始字符串,但我们希望在其中包含一些变量:
v1 = 3
pattern = re.compile(rf'^E{v1}\.{v1+1}$')
串起熊猫和熊猫
到目前为止,我们讨论的都是 Python 中的原始字符串类型,我们还没有涉及到在其他流行的 Python 包中如何处理字符串。在这里,我将分享一些 Numpy 和 Pandas 中的字符串类型。
在 Numpy 中,通常可以用三种不同的“数据类型”来指定字符串:
- 可变长度 Unicode (U)
- 固定长度字节
- Python 对象(O)
import numpy as np
arr1 = np.array(['hello','hi','ha'],dtype='<U5')
arr2 = np.array(['hello','hi','ha'],dtype='|S5')
arr3 = np.array(['hello','hi','ha'],dtype='object')> array(['hello', 'hi', 'ha'], dtype='<U5')
> array([b'hello', b'hi', b'ha'], dtype='|S5')
> array(['hello', 'hi', 'ha'], dtype=object)
<U5
表示最长的是一个长度为 5 的字符串,然而,一个更节省内存的方法是使用固定长度的|S5
,它本质上是将它们转换成字节字符串。如果您试图将其转换为强类型数据结构(即存储为 h5 文件),这是首选方式。此外,我们可以将字符串视为 Python 对象,并将它们存储在 Numpy 数组中,因为每个 Python 对象都可以使用“object”类型存储在 Numpy 数组中。
熊猫身上的弦可以用两种方式来表示:
- 对象数据类型(大多数时候应该没问题)
- 字符串数据类型
import pandas as pd
s1 = pd.Series(['hello','hi','ha'],dtype='object')
s2 = pd.Series(['hello','hi','ha'],dtype='string')> s1
0 hello
1 hi
2 ha
dtype: object> s2
0 hello
1 hi
2 ha
dtype: string
这两种类型大体相似,细微的差别在文档中有所概述。
结论
总之,我们讨论了 Python 中“字符串”的不同表示。从默认的 **Unicode 字符串(u 字符串)开始,我们讨论了它与字节字符串(b 字符串)**的关系。理解这种转换非常重要,因为有时来自其他程序的标准输出将是字节格式,我们需要首先将它们解码为 Unicode 字符串,以便进行进一步的流操作。然后我们谈到了 Raw string (r string) 和 Format string (f string) 以及使用它们时需要注意的事项。最后,我们总结了 Numpy 和 Pandas 中字符串表示的不同方式,在用 string 实例化 Numpy 或 Pandas 对象时应该特别小心,因为它们的行为与原始的 Python 字符串有很大的不同。
差不多就是这样!我希望你觉得这篇文章有趣和有用,感谢阅读!如果你喜欢这篇文章,请在 medium 上关注我,非常感谢你的支持。在我的 Twitter 或 LinkedIn 上联系我,也请让我知道你是否有任何问题或你希望在未来看到什么样的教程!
C++基础:数组数据结构
C++提供了不同类型的数组,了解它们的内部工作方式将有助于我们为自己的应用选择正确的类型
照片由 Fotis Fotopoulos 在 Unsplash 上拍摄
数组
当我们用任何编程语言编码时,最重要的事情之一是选择正确的数据结构来表示我们的数据。这一点很重要,因为我们不希望我们的应用程序变慢到成为瓶颈,或者在应用程序扩展时使用过多的内存。
其中一种数据结构是数组,它是一个连续的内存块,可以存储许多相同数据类型的变量(元素)。
内存中的数组(图片由作者提供)
假设我们有一个大小为 4 字节的数据类型,我们有 6 个字节。上图直观地显示了我们的数据是如何存储在内存地址 0x00000000 中的,数字 0 到 5 是元素的索引。
数组存储在连续的内存块中这一事实意味着数组可以提供以下功能:
int data[6];
- 随机访问任何元素
例如,我们可以用**数据【3】**访问索引为 3 的元素 - 使用指针偏移量
不仅使用下标操作符,我们还可以使用指针偏移量,例如,我们可以用 *(data + 3) 访问索引 3 处的元素参见指针算法
正如我们在上面看到的,无论我们使用下标操作符还是使用常规指针的偏移量,读取特定位置的元素都没有开销或者 O(1)。但是对于搜索,复杂度是 O(n ),假设我们不对数组进行排序,这不是本文的重点。
插入和删除等其他操作取决于我们选择的数组类型。我们将在接下来的章节中讨论细节。
c 风格数组
C++支持固定大小的 C 风格数组,有时也称为裸数组。这就是我们如何声明一个数组。
int data[6];
这意味着我们有 6 个整数存储在连续的内存块中。我们也可以直接初始化我们的数据。
int data[6] = {1, 2, 3, 4, 5, 6};
或者,
int data[] = {1, 2, 3, 4, 5, 6};
根据我们声明数组的位置和方式(局部/全局和初始化/未初始化),可以在堆栈或数据/bss 上创建数组。我们还可以使用 new[] 操作符在堆内存上创建数组,并使用 delete[] 操作符销毁它们。这些运算符不同于新增和删除运算符,不要混淆。
int *data = new int[5];
delete[] data;
数组/指针二元性
c 风格的数组可以退化为指针。这意味着它们可以被转换成指针,并在这个过程中丢失它们的类型和大小。我们可以将指针用作数组,反之亦然。
int data[6] = {1, 2, 3, 4, 5, 6};std::cout << *(data+3);
上面的代码将打印“4”。
void print_array(int *data, int len)
{
for (int i=0; i<len; i++)
{
std::cout << data[i] << "\n";
}
}
上面的代码将打印 1 到 6。正如我们所看到的,我们的数组类型退化为指针类型,并在此过程中丢失了大小信息,这就是为什么我们将长度/大小作为参数传递。
固定大小的数组
C++标准模板库或 STL 在 std::array 中提供了固定大小的数组,本质上与 C 风格数组 相同,封装在 structs 中的 保存 C 风格数组,带有额外的标准 STL APIs,如访问元素、返回迭代器、检查容量等。
就像 C 风格的数组一样,根据我们声明它们的位置和方式,std::array 可以在 stack 或 data/bss 上创建。
以下示例假设我们使用 C++17 之前的 C++版本。
std::array<int, 6> data{1, 2, 3, 4, 5, 6};
它不会衰减成指针类型
除了拥有标准的 STL APIs,C 风格数组的另一个不同之处是它不会退化成指针。当我们把它传递给一个函数时,我们需要指定类型和大小。
void print_array(const std::array<int, 6>& data)
{
for (const auto& x : data)
{
std::cout << x << "\n";
}
}
好的一面是,由于它保留了大小信息,我们可以使用 for-range 循环。它也更安全,因为如果类型或大小不匹配,我们会得到一个编译错误。
std::array<int, 6> data{1, 2, 3, 4, 5, 6};void print_array(const std::array<int, 5>& data)
{
for (const auto& x : data)
{
std::cout << x << "\n";
}
}
我们将得到以下错误消息:
error: invalid initialization of reference of type ‘const std::array&’ from expression of type ‘std::array’
如果我们希望函数支持不同的类型或大小,我们可以将函数创建为模板:
template <typename T, std::size_t size>
void print_array(const std::array<T, size>& data)
{
for (const auto& x : data)
{
std::cout << x << "\n";
}
}
它提供了 STL APIs
我想说的最大好处是它提供了 STL APIs,所以我们可以使用 STL 算法来处理我们的数组。虽然我们也可以使用 C 风格的数组来使用 STL 算法,但是代码是不可移植的,比如当我们想换成另一种类型的容器时,比如我们将在下一节讨论的 std::vector 。
对于 C 风格的数组,我们可以使用 std::find_if 算法如下:
int data[6] = {1, 2, 3, 4, 5, 6};
int *result = std::find_if(data, data+6,
<const int x>
{
if (x == 4) return true;
else return false;
});
用 std::array ,看起来是这样的:
std::array<int, 6> data{1, 2, 3, 4, 5, 6};
auto result = std::find_if(data.begin(), data.end(),
<const int x>
{
if (x == 4) return true;
else return false;
});
我们可以简单地用 std::vector 替换 std::array,它仍然可以工作,如下所示。
std::vector<int> data{1, 2, 3, 4, 5, 6};
auto result = std::find_if(data.begin(), data.end(),
<const int x>
{
if (x == 4) return true;
else return false;
});
有很多标准 API 比如 back() 或者 front() 分别访问最后一个和第一个元素。
随机存取和指针偏移
就像 C 风格的数组一样,std::array 通过下标操作符为随机访问提供了灵活性:
std::array<int, 6> data{1, 2, 3, 4, 5, 6};
int x = data[3];
以及获取原始指针和使用偏移量来访问数据:
std::array<int, 6> data{1, 2, 3, 4, 5, 6};
int *pData = data.data();
int x = *(pData+3);
动态大小数组
在许多情况下,固定大小的数组不是我们编码问题的解决方案。例如,当我们不知道数据的大小或者数据的大小在运行时自然变化时。
C++为动态大小的数组提供了 std::vector。要声明 std::vector,我们只需指定类型:
std::vector<int> data;
如果你正在用 C++写代码,我相信你已经用了很多这种数据结构,因为如果你不知道使用哪种数据结构,大多数人会说这是最好的数据结构。
但是了解它是如何工作的是很重要的,这样下次你就可以更明智地选择你的数据结构。
std::vector 从堆中分配内存
std::vector 从堆中动态分配内存来存储我们的数据。当我们的 std::vector 对象超出范围时,它会将内存返回给系统。
默认情况下,如果我们不指定它,std::vector 将使用 std::allocator 来分配内存。std::allocator 的内部结构并不是本文的重点,出于本文的目的,我们假设它的工作方式就像 C 编程语言中的 malloc() 一样。
因为我们的数据大小是动态的 std::vector 在运行时分配和重新分配内存。为了避免每次添加或删除元素时都要调整内存大小(释放和分配),std::vector 会根据大量内存调整大小,比如将当前大小增加一倍。不同编译器的实现细节可能有所不同。
让我们看一个使用 gcc 的例子:
std::vector<int> data{1, 2, 3, 4};
如果我们通过调用 data.capacity()来查询元素的数量,它将返回 4。当我们再添加一个元素时:
data.push_back(5);
现在尺寸变成了 8,而不是 5。如果我们再增加 4 个元素:
data.push_back(6);
data.push_back(7);
data.push_back(8);
data.push_back(9);
尺寸变为 16,而不是 9。我们可以看到,每当先前分配的内存满了,大小就会翻倍。
内存增长示例(图片由作者提供)
它不会自动收缩
我们需要注意的一点是,当我们通过调用 pop_back() 或 erase() 删除元素时,std::vector 不会自动收缩。
继续我们上面的例子,即使我们删除了 8 个元素,大小仍保持不变:
for (int i=0; i<8; i++)
{
data.pop_back();
}
数据 对象仍然拥有 16 个元素的容量。如果我们想要移除未使用的内存,我们必须通过调用 shrink_to_fit() 显式地要求它收缩:
data.shrink_to_fit();
迭代器失效
在使用动态大小数组 std::vector 时,我们需要注意的另一件事是使用迭代器。迭代器是我们在使用 STL 时经常使用的类似指针的对象。
迭代器指向 std::vector 中的一个元素,例如,调用 begin() 将返回一个指向 std::vector 中第一个元素的迭代器。
std::vector<int> data{1, 2, 3, 4};
auto it = data.begin();
迭代器(作者图片)
如果 std::vector 的内存发生变化,这个迭代器可能会失效。
data.push_back(5);
在这种情况下,添加一个元素会触发重新分配,这是一个新的内存块。现在我们的迭代器无效了,因为它仍然指向旧的位置。下图说明了内部发生的情况:
无效的迭代器(图片由作者提供)
摘要
总而言之,在很多情况下,我们需要将数据存储在连续的内存块中。数组数据结构的特征如下:
- 恒定访问时间,包括随机访问和指针偏移
- 没有/较少内存分配开销
为了保存少量已知大小的数据,我们可以使用固定大小的数组,这在分配内存方面没有成本,对于 c++我们可以使用 std::array。
当我们需要存储较大的数据时,可以使用动态数组 std::vector。使用 std::vector 时要记住几件事:
- 它可能会分配比优化插入时间所需更多的内存
- 尺寸不会因为移除元素而自动缩小
- 当内存改变时,迭代器失效
- 它不支持 push_front()和 pop_front(),因为这两个操作总是会触发内存中的数据转移,如下图所示
在开始添加新元素时移动数据(图片由作者提供)
如果您需要频繁地在数据的开头插入和/或删除元素,c++提供了 std::deque,这是一个针对这种情况优化的双端队列。
我没有把它放在这篇文章中,因为 std::deque 不连续存储数据。它实现为数组的集合,你可以把它想象成 std::vector 的链表。
https://debby-nirwan.medium.com/subscribe
C++基础:朋友
在什么场景下应该使用 C++中的 Friend?
由 Pakata Goh 在 Unsplash 上拍摄的照片
介绍
C++中 Friend 的使用对于初学者来说往往是比较混乱的,因为网上有很多关于使用它是会增加封装性还是会打破封装性的争论。
如果您是面向对象编程的新手,这一点尤其正确——面向对象编程的要点是封装的概念,即通过阻止其他类访问类的内部状态来限制对类的内部状态的访问。
C++中的友元概念是一种机制,一个类可以故意让其他类或函数访问它的内部状态。现在你可以明白为什么有些人认为这违背了 OOP 的要点。
在这篇文章中,我们将看到 C++中的 friend 概念,它的用途,以及如何使用 friend 函数和 Friend 类的细节。在那之后,我们将会看到我们应该在什么样的场景中使用它们,从不同的来源中总结。
C++中的朋友概念
当我们在一个类中声明一个友元时,我们授予该友元对该类的私有和受保护 成员的访问权。这意味着朋友可以访问类的成员变量和成员函数。
如果我们不将其声明为友元,访问私有或受保护的成员将导致编译错误。
编译这段代码给我们提供了:
In function ‘void Print(const Test&)’:
error: ‘void Test::Print() const’ is private within this context
C++中的访问规则由编译器检查,因此编译错误。
在这个例子中, Print() 函数 违反了访问规则 导致编译错误被抛出。为了使编译通过,我们可以将 Print() 函数声明为 Test 类的朋友。
只需添加一个朋友声明,我们就可以构建并执行我们的程序,该程序将打印号码:
2 3
我可以把好友声明放在哪里?
我们可以把友元声明放在类体的任何地方,因为访问说明符不会影响它。在这个例子中,我把它公开,但它可以在任何地方。
朋友不是继承的,不是传递的,也不是互惠的
在引言部分,提到了宣布朋友是一个深思熟虑和明确的过程。这意味着我们有以下限制:
- 朋友不是继承的:朋友类的孩子不是朋友。
- 朋友是不可传递的:朋友的朋友不是朋友。
- 朋友不是对等的:我不能访问我朋友的内部状态,除非他/她也声明我是朋友。
所有这些限制都是为了确保类的作者慎重选择是否允许朋友访问其他类和函数。
在下一节中,我们将看到如何声明友元函数和类的细节。
朋友函数
我们可以在类体中将成员函数和自由函数都声明为友元。
对于自由函数,它非常简单,不需要前向声明。我们可以简单地声明朋友如下:
***void Print(const Test&Test)***函数可以访问 Test 类的私有成员。
对于成员函数,它不像自由函数那样简单。即使向前声明具有该函数的类也是不够的。以下内容将导致编译错误。
error: invalid use of incomplete type ‘class Printer’
In member function ‘void Printer::Print(const Test&)’:
error: ‘void Test::Print() const’ is private within this context
原因是 Printer 类中没有声明**void Printer::Print(const Test&Test)函数。编译器只从 forward 声明中知道有一个名为 Printer 的类。我们可以通过向上移动打印机类并向前声明测试类来修复错误。
朋友类
不仅仅是函数,我们还可以声明一个类作为我们类的朋友。在现代 C++中,有两种方法可以声明友元类:
- 朋友类 F;
- 朋友 F;
如果没有现有的类,前者将声明一个新的类,而后者只有在该类存在的情况下才起作用。以下代码编译无误,因为声明友元时引入了一个新的类 Printer 。
但是下面的代码失败了,因为编译器找不到打印机类。
error: ‘Printer’ does not name a type
当然,我们可以通过向前声明打印机类或者改变类声明的顺序(并向前声明测试类)来简单地解决这个问题。所以最简单的解决方法是前者。
什么时候我们应该使用朋友?
现在我们已经了解了如何在 C++中正确声明友元,我们需要知道何时应该使用它们。
一些使用案例如下:
- 界面设计灵活性
- 运算符重载
两者都是自由函数,对于成员函数和类,我能找到的唯一用例是数据结构实现的一部分(细节和例子见 Wikipedia )。
界面设计灵活性
从下面的例子中,我们有两个不同的选项来调用 Print() 函数。
我们可以从以下表格中选择可读性更强的表格:
Test test(2, 3);test.Print(); // Option 1
Print(test); // Option 2
有些人认为选项 2 比选项 1 可读性更强。无论如何,你可以灵活地选择如何设计你的界面。
运算符重载
出于调试目的,我们希望重载的最常见的操作符是插入操作符。例如:
std::cout << test;
我们不能将这个操作符实现为成员函数,因为第一个参数的类型是 std::ostream。
如何将单元测试类声明为友元类?
对此有相反的意见,但就个人而言,我同意这种观点,即我们应该只测试公共接口。
如果我们发现很难涵盖公共接口的所有情况,这可能表明我们的类太大了,做了太多的事情,因此我们可能想要重构它。
参考
https://docs.microsoft.com/en-us/cpp/cpp/friend-cpp?view=msvc-160 https://stackoverflow.com/questions/4171310/what-is-wrong-with-making-a-unit-test-a-friend-of-the-class-it-is-testing
C++基础:移动资源
什么时候应该写自己的 move 构造函数和 move 赋值操作符?
照片由 Unsplash 上的 Fotis Fotopoulos 拍摄
简介—为什么要移动资源
在编写程序时,你会遇到需要将(大量)资源从一个对象转移到另一个对象的情况。
在 C++中,我们有移动语义,这是一种移动资源的方式,以避免在内存中进行复制,这不仅会使我们的程序变慢,而且会使用更多不必要的空间。
我们在这里不讨论移动语义,因为你可以在互联网上找到很多解释右值和移动语义的资源。
不太明显的是,什么时候我们可以依靠编译器来帮助我们移动资源,什么时候我们必须编写自己的移动构造函数和移动赋值操作符。
我们将在下面的章节中看到一些例子。当然,要求你使用现代 c++,至少是 c++11。
隐式声明和定义的特殊成员函数
如果我们创建一个像下面这样的简单结构,编译器会隐式地为我们生成一些特殊的函数,这样我们就不用写冗长的代码了。我们将由编译器生成以下函数:
- 默认构造函数
- 复制 ctor
- 移动构造函数
- 复制赋值运算符
- 移动赋值运算符
- 破坏者
了解这一点对于我们理解在管理(大量)资源时是否需要编写它们是很重要的。
移动资源
我们可以用多种方式管理资源,最常见的方式是使用 std::vector,但在其他情况下,我们可能希望使用原始指针或智能指针。
我们可能还需要在创建包装器时管理操作系统的资源,例如像 Linux 中的套接字句柄。
用 std::vector 管理资源
在编写管理向量的类时,我们不必专门编写 move 构造函数和 move 赋值操作符,因为 std::vector 已经为我们实现了。请看下面的例子:
在我们的 数据 类中,我们只实现了一个默认的构造函数,仅此而已。但是正如你看到的,我们可以复制和移动我们的资源。
Data data2 = data;
上面一行调用复制构造函数,下面一行调用移动构造函数。
Data data3 = std::move(data);
如果我们看到程序的输出,我们会看到类似这样的内容:
Data's internalData is at: 0x558d72e74eb0
Data2's internalData is at: 0x558d72e75460
Data3's internalData is at: 0x558d72e74eb0
data is now empty
我们可以看到 数据 2 具有不同的地址,这是因为资源被复制到内存中的新空间,而 数据 3 与 数据 具有相同的地址,因为资源刚刚被移动。结果, 数据 变空,因为它的资源已经从中释放。
智能指针呢?
有共享指针和唯一指针,但我们在这里将重点放在共享指针上,因为唯一指针不允许你复制它,因为它必须是唯一的:),它只能移动。
这个程序的输出是:
Data's internalData is at: 0x5599c3db8ec0
Number of owners: 1
Data2's internalData is at: 0x5599c3db8ec0
Number of owners: 2
Data3's internalData is at: 0x5599c3db8ec0
Number of owners: 2
data is now null
在这种情况下,我们的地址都是一样的,这是因为 shared_ptr 是为了共享资源,所以当你通过调用这行代码进行复制时:
Data data2 = data;
资源没有被复制,但是它们现在是共享的,这可以从所有者的计数中看出,在那一行之后变为 2。
现在如果我们调用下面的行:
Data data3 = std::move(data);
调用 move 构造函数, data3 的 internalData 指向与 data2 的 internalData 相同的地址,但是现在 data 已经无法访问这些资源,因为它们已经被转移到 data3 。
在这种情况下,我们也可以依靠编译器来完成它的工作,为我们实现 move 构造函数(以及所有其他特殊的成员函数)。
原始指针呢?
在某些情况下,我们可能想要管理原始指针,让我们看一个例子。
就像前面的例子一样,我们试图依靠编译器来完成它的工作。该程序的输出如下:
Data's internalData is at: 0x5565b0edaeb0
Data2's internalData is at: 0x5565b0edaeb0
Data3's internalData is at: 0x5565b0edaeb0
它们都指向同一个地址,这里有点不对劲。至少我们期望 Data2 的 internalData 指向不同的地址,因为它应该复制。
这显然行不通。原因是隐式生成的复制构造函数对成员进行了成员式复制,所以复制的是地址,而不是数据。
代码中缺少的另一件重要的事情是,当对象被销毁时,我们没有释放内存,这将导致 内存泄漏 。所以我们需要写自己的析构函数。
在我们添加了析构函数之后,会发生什么呢,当我们执行它的时候,这个程序会崩溃。
Data's internalData is at: 0x5632d3066eb0
Data2's internalData is at: 0x5632d3066eb0
Data3's internalData is at: 0x5632d3066eb0
double free or corruption (!prev)
Aborted (core dumped)
这是因为我们没有正确实现下面的特殊成员函数:
- 复制构造函数
- 移动构造函数
- 复制赋值运算符
- 移动赋值运算符
现在让我们将完整的实现编写如下:
输出将是正确的,如下所示:
Data's internalData is at: 0x5638e02c2eb0
Data2's internalData is at: 0x5638e02c4270
Data3's internalData is at: 0x5638e02c2eb0
Data is now empty
Data2 的 internalData 现在指向不同的地址,有自己的数据副本,而 Data 在其 internalData 被移动到 Data3 后变为空。
结论
在试验了上面的不同场景后,我们现在可以得出结论,当我们的类管理原始资源(如原始指针)和操作系统句柄(如套接字)时,我们只需要编写自己的移动构造函数和移动赋值操作符。否则,我们可以依靠编译器为我们生成它们。
三/五法则
要记住的一件重要事情是三法则,它说:
如果您需要显式声明析构函数、复制构造函数或复制赋值操作符,您可能需要显式声明这三者。
对于现代 C++来说,我们需要增加两个函数,即移动构造函数和移动赋值操作符,这就是五的规则。
在上面的例子中,我们需要编写析构函数,所以我们需要所有五个特殊的成员函数。
C++基础:理解异常处理
为什么它是处理错误的更好方法?它是如何在引擎盖下工作的?让我们后退一步,看看它到底提供了什么。
凯文·Ku 在 Unsplash 上的照片
什么是异常处理?
软件中的异常是指阻止软件执行常规路径的错误情况。这些错误可能是软件本身可以控制的,如错误的参数或超出其控制范围。例如,打开文件、套接字、分配内存块等系统调用返回的错误。
异常处理提供了一种更好的机制来检测和处理错误。为了理解为什么它更好,现在让我们看看在没有异常处理的情况下,我们如何检测和处理错误,比如在像 c 这样的编程语言中。
检测和处理错误的传统方法
我们通常将程序分解成多个函数或子程序,使其模块化,更容易理解。这意味着我们的程序将有多个链接的函数调用。每个函数都可以返回一些信息供调用者使用。
让我们看一个简单的程序,将两个必须小于 100 的整数相加。我们可以这样写程序:
错误传播 1(作者编写的代码)
所有涉及的函数都返回一个整数,当我们遇到错误时,我们返回 -1 。但是我们这里遗漏了一些东西, -1 可能是加法的结果。我们可能想要添加一个检查来确保参数不小于 0 。但是现在,在主函数中,当我们收到一个错误时,我们不知道它是哪一个,是 < 0 还是 > 100 。我们需要添加一个全局变量**。**
错误传播 2(作者编写的代码)
所以现在我们有了一个检测错误和处理错误的机制。根据您的程序,函数调用可能会更长。
这是我们以传统方式处理错误的方式,例如当我们想用 C 编程语言打开一个文件时:
FILE *fp;
fp = fopen ("file.txt", "w+");
如果 fp == NULL ,则操作失败,全局变量 errno 被更新。
传统方法的一些问题
上述传统方法存在一些问题。这些是其中的一些:
- 所有涉及的函数必须返回相同的类型,例如 integer 来传播错误状态。这些函数调用可能相当长,并且它们被强制返回相同的类型。
- 在处理程序的函数调用返回后,必须立即检查全局变量,或者缓存全局变量。因为当随后发生另一个错误时,它可能被更新。
- 是否处理错误取决于调用者。如果不处理它,可能会导致程序稍后崩溃或程序异常继续。
用异常处理来处理错误
异常处理如何改进传统方法?
对于初学者来说,它在知道错误的代码和知道如何处理错误的代码之间提供了一个清晰的分隔,并且中间的所有代码都可以安全地忽略错误。
带异常处理(由作者编写的代码)
有了异常处理,我们的代码现在看起来不同了。中间的函数 add_wrapper() 不一定要返回 integer 类型。它不需要知道错误。当抛出一个错误时,将在捕获该错误的 main() 函数中进行处理。现在,无论您在错误检测器和错误处理程序之间添加多少函数,它们都可以忽略它们不应该关心的错误。
我们可以去掉全局变量。
关于最后一点,如果没有处理异常,程序将会终止,因为 C++运行时将调用 std::terminate。
如果我们删除代码中的 try-catch 并执行它,我们会得到以下结果:
terminate called after throwing an instance of 'std::invalid_argument'
what(): parameters must be >= 0
Aborted (core dumped)
所以它是更安全的,从某种意义上说,程序不会继续运行,我们会得到一些关于发生了什么的信息。
现在我们已经看到了异常处理提供了什么,让我们深入了解更多的细节。
C++中的异常处理
上面我们已经看到了如何用 C++编写异常处理,它由两部分组成:
- 错误检测器:我们调用 throw 语句的地方
- 错误处理程序:我们编写 try-catch 语句的地方
异常作用于函数
异常通过在所有相关函数中添加额外的信息和代码来工作。所涉及的功能不仅包括探测器和处理器还包括在之间的所有功能。
现在你可以看到这不是魔术,所有涉及的功能必须仍然做一些事情来实现这个错误报告。
不同的是,所有这些工作都是由编译器 为我们完成的 ,所以从我们的代码中是看不到的。这就是为什么我们需要理解这个概念,因为仅仅看代码是不明显的。
添加到参与异常的所有函数中的额外信息和代码都被添加到相关函数代码的末尾,位于称为 LSDA(语言特定数据区)的区域。
LSDA 包含函数是否能捕获异常、异常的类型以及如何清理函数的信息。
无一例外的函数代码(图片由作者提供)
异常的函数代码(图片由作者提供)
如上所示,当我们使用异常时,编译器会为我们生成额外的代码。
实现细节是特定于语言和编译器的,通常通过一个名为 的函数来实现个性函数 ,它使用 C++运行时的元数据(见上图)来:
- 搜索可以处理所引发的异常类型的处理程序
- 执行清理程序,如销毁物品
当调用 throw 语句时,C++运行时将搜索处理程序,并在一个称为堆栈展开的过程中执行清理例程。根据编译器的不同,这可能是一次通过或两次通过的过程。
在一次通过过程中,每次展开返回一个功能时,搜索之后是清除过程,直到找到 try-catch 。
在两遍过程中,搜索在没有清理的情况下完成,只有在第二遍中找到处理程序时才执行清理例程。
安全设计
在 C++中有一些我们需要知道的细节,以确保我们的代码是安全编写的。
内存泄漏(作者代码)
如果 add() 抛出异常,上面的代码可能会导致内存泄漏。因为最后两行没有执行。编译器不能为这个函数生成 cleanup 例程,它能做的就是生成 cleanup 来调用析构函数,但是 temp 不是对象。
记住这一点的最简单的方法是不要在所有涉及的函数中使用指针。
另一个细节是构造函数中引发的异常。如果在构造函数中引发异常,编译器不会生成清理例程。
ctor 中的异常(作者代码)
在上面的代码中,我们有一个内存泄漏,因为没有进行适当的清理。编译器不会为***error propagator()***函数生成清理例程。我相信是因为物体被认为是部分创造的。
没有清理的异常(图片由作者提供)
为了解决这个问题,我们应该避免在构造函数中使用原始指针,而是使用包装器。并且,我们所有的对象都应该实现 RAII(资源分配是初始化)习语%20to%20the),以确保所有的对象都被清理。
这是因为清理例程是为构造函数生成的。下面我们来看看正确的版本。
正确清除的 ctor 中的异常(由作者编写的代码)
这样,我们就为构造函数生成了一个清理例程。
清理时出现异常(图片由作者提供)
没有例外——打破链条
有些情况下,我们不想捕捉或传播异常。C++通过在函数名末尾添加一个 noexcept 关键字提供了一种方法。如果我们通过向***error propagator()***函数添加一个 noexcept 关键字来修改上面的代码,则不会生成元数据,并且对错误处理程序的搜索将会失败,这将导致 C++运行时调用 std::terminate。
void ErrorPropagator() noexcept
{
Error error;
error.Test();
}
结果是:
terminate called after throwing an instance of 'std::invalid_argument'
what(): wrong error parameter
Aborted (core dumped)
除了错误传播器(图片由作者提供)
摘要和参考文献
我们现在知道了异常处理可以解决什么问题,它是如何工作的——编译器添加了额外的代码,以及我们在编写 C++代码时需要注意什么。
理解这个概念有助于我们写出更好的 C++代码。
一些好的参考
https://docs.microsoft.com/en-us/cpp/cpp/exception-handling-in-visual-cpp?view=msvc-160 http://systemtbe.blogspot.com/2017/02/notes-on-c-exception-handling-and-stack.html https://www.codeproject.com/Articles/2126/How-a-C-compiler-implements-exception-handling
C++基础:理解对象模型
当你开始学习 C++的时候,首先要了解的是对象模型。在你理解了对象模型之后,其他的一切都将变得有意义。
阿诺·弗朗西斯卡在 Unsplash 上的照片
介绍
C++可能很难学,尤其是如果你来自 Python 这样的高级编程语言。通过了解基础知识来开始你的学习之旅是非常重要的。
其中之一就是理解一个物体是什么。它在记忆中是如何表达的,以及它与语言中的其他概念是如何联系的。
在本文中,我们将研究 C++中对象模型的细节。
对象模型
我们从理解对象的定义开始。在 C++标准中,对象是内存中的一个存储区域。
看到它是如何存储在内存中的,会更容易理解。假设我们有下面的结构。
普通旧数据— POD
它非常简单,只有两个整数。根据我们声明对象的位置,可以在数据、堆栈或堆中创建对象。但是对于这个例子,让我们假设我们将它声明为一个局部变量,因此存储在堆栈中。如果我们打印地址,我们会得到:
start addr: 0x7ffd04a11be0
a: 0x7ffd04a11be0
b: 0x7ffd04a11be4
就像 C 语言中的结构一样,我们的对象只是内存中两个整数的集合。
具有成员函数
现在,让我们看看当我们在结构中添加一个成员函数时会发生什么。
当我们打印成员变量的地址时,它们保持不变:
start addr: 0x7ffd04a11be0
a: 0x7ffd04a11be0
b: 0x7ffd04a11be4
这是因为函数存储在其他地方——在文本/代码部分,而不是数据/堆栈/堆中成员变量存储的一部分。
内存中的对象(图片由作者提供)
成员函数就像非成员函数一样,只要程序运行,它们就存在于文本/代码部分。
多重实例化
如果我们构造两个物体呢?就像下面的代码一样。
正如你所猜测的,我们在内存中的不同区域有两个对象:
start addr of data1: 0x7ffe79920fb8
data1.a addr: 0x7ffe79920fb8
data1.b addr: 0x7ffe79920fbcstart addr of data2: 0x7ffe79920fc0
data2.a addr: 0x7ffe79920fc0
data2.b addr: 0x7ffe79920fc
但是成员函数呢?它只有一个,存储在文本部分。
内存中的多个对象(图片由作者提供)
现在,你会想当我们调用 GetSum() 时会发生什么?它如何知道使用哪些数据?
这就是 的神奇之处,这个 指针发挥了它的作用。编译器将 这个 指针添加到我们的函数中,并修改函数内部的成员访问。
除了添加隐藏的 这个 指针,编译器还将 const 关键字移入括号内,所以现在constcv-qualifier 也开始有意义了。就是说 这个 指针是一个指向常量对象的指针。
除此之外,编译器将我们函数的名称改为如下形式:
成员函数被编译器重命名,它就像一个非成员函数。
在调用方,编译器修改代码,将对象的地址传递给函数,以便函数可以使用正确的对象。
现在我们已经解决了这个由编译器执行的魔术,并且理解了它是如何工作的。这里似乎没有什么新奇的东西,就像我们在 C 编程语言中做的一样,只是现在编译器为我们做了。
静态成员函数
在我们理解了编译器如何修改我们的函数之后,现在就很容易理解什么是静态成员函数了。
对于静态成员函数,编译器不添加 这个 指针它只改变函数的名字。
静态成员函数和非成员函数之间的唯一区别在于我们如何调用它们(作用域)。
更复杂的对象
现在让我们看看更复杂的对象,比如具有用户定义类型(非原语)、继承和多态的对象。
用户定义的类型
我们已经看到了一个带有基本成员的简单结构,现在让我们看一个更复杂的例子。
地址打印如下:
start addr of complexData: 0x7ffc95b94bf0
complexData.a addr: 0x7ffc95b94bf0
complexData.data.a addr: 0x7ffc95b94bf4
complexData.data.b addr: 0x7ffc95b94bf8
complexData.b addr: 0x7ffc95b94bfc
所以没什么特别的。它们就像我们订购时那样摆放。
使用用户定义的类型(按作者分类的图像)
遗产
就像用户定义的类型一样,数据的布局和继承没有什么特别的。请参见以下示例:
这是它在内存中的样子:
继承(作者图片)
父代成员放在子代成员之前。
多态性
这就是我们引入静态与动态绑定概念的地方。在这个上下文中,绑定指的是将函数调用和函数定义链接起来的过程,更准确地说是函数在内存中的地址。
静态绑定是指过程发生在编译时,而动态绑定是指过程发生在运行时。
在 C++中实现多态性需要这些过程。
多态的一种形式是函数重载,这是一个创建同名但参数不同的函数的过程。让我们看看我们的例子。
实际情况是,我们在内存中有两个不同的函数,名字不同。
函数重载(图片由作者提供)
函数调用到函数定义的链接可以在编译时完成,因为所需的信息在编译时是可用的。编译时还会链接以下内容:
- 正常函数调用
- 运算符重载
多态的另一种形式是函数覆盖,这是一个创建函数来覆盖基类/父类的函数的过程。基类和子类中的两个函数具有相同的名称和参数。请看这个例子:
您可能已经猜到,这段代码在编译时会产生两个不同的函数:
int Data::GetSum(const Data* this);
int ComplexData::GetSum(const ComplexData* this);
就像函数重载的例子一样,链接发生在编译时。这个代码
int main()
{
Data data;
ComplexData complexData;
int b = complexData.GetSum();
int a = data.GetSum();
return 0;
}
在编译时更改为以下内容
int main()
{
Data data;
ComplexData complexData;
int a = ComplexData::GetSum(&complexData);
int b = Data::GetSum(&data);
return 0;
}
虚拟功能
我们想用函数覆盖做的一件事是能够从基类访问不同类型的子类,这提供了一个有用的抽象机制。当我们编写这段代码时:
void PrintSum(const Data& data)
{
std::cout << data.GetSum() << "\n";
}int main()
{
ComplexData complexData;
PrintSum(complexData);
return 0;
}
我们想要的数据。GetSum() 调用complex Data::GetSum(),而不是Data::GetSum()。
现在的问题是,我们在编译时没有信息将调用者链接到正确的函数定义。编译器只知道在 PrintSum() 函数中类型是 数据 。
这里我们可以使用动态绑定来强制编译器在运行时在调用者和正确的函数定义之间进行匹配。在 C++中,这可以通过在基类的函数声明中添加虚拟关键字来实现。
有了这个声明,在这段代码中
void PrintSum(const Data& data)
{
std::cout << data.GetSum() << "\n";
}int main()
{
ComplexData complexData;
PrintSum(complexData);
return 0;
}
数据。GetSum()** 会正确调用complex data::GetSum()。
虚拟指针和虚拟表
现在,让我们来看看当我们在类中使用虚拟函数使动态绑定成为可能时,我们的对象会发生什么。
虚拟函数覆盖(图片由作者提供)
我们的对象现在看起来不同了,我们有了一条额外的信息来存储名为虚拟指针的虚拟表的地址— vptr 。
虚拟表是一个函数查找表,用于将调用者链接到正确的函数定义。它通常存储在文本/常量部分中。
我们现在可以明白为什么这个过程被称为动态/后期绑定了。因为调用函数的过程是通过 vptr 完成的。
vptr 是怎么设置的?它在构造函数中设置,从基类开始,直到层次结构中最低的派生类。对于我们的示例,如下所示:
Data::Data()
{
vptr = &data_vtable;
}ComplexData::ComplexData()
{
vptr = &complexData_vtable;
}
它首先被设置为基类 Data 的 vtable,然后被设置为派生类 ComplexData 的 vtable。
现在我们明白了为什么我们的程序可以在不知道对象类型的情况下调用正确的函数。
结论
在我看来,在理解更高层次的概念之前,先理解好基本面是非常重要的。
通过理解对象在 C++中是如何建模的,我们可以理解为什么我们必须以特定的方式编写 C++代码。同样重要的是,我们知道何时以及为什么我们需要使用动态绑定和静态绑定。
我们也知道为什么人们说使用虚函数是有“成本”的,因为动态绑定的代码中有额外的步骤。
我希望这些信息对你有用。
参考
用于数据处理的 C++内存分配/释放
理解如何管理内存将有助于我们更明智地分配/释放内存。
概观
除非您正在运行 RTOS 或裸机的资源非常有限的嵌入式系统上工作,否则几乎肯定需要动态分配内存来处理数据。在 C++中有许多动态分配内存的方法,比如使用 new 和 delete 操作符以及它们的对应操作符 new[] 和 delete[] 、 std::allocator ,或者 C 的 malloc()。
无论采用哪种方法,系统都必须连续分配内存块。
C++ STL 提供了许多方便的库,比如容器,它们也在内部动态分配内存。C++中的动态内存分配随处可见。
在本帖中,我们将讨论在 C++中如何管理内存,以便我们可以更明智地使用它。
存储配置
物理和虚拟内存
请记住,这个内存布局是用户空间应用程序的虚拟内存布局。在像 Linux 这样的系统中,物理内存大体上分为内核空间和用户空间,对于应用程序,我们说的是用户空间。此外,系统中的每个进程都分配有虚拟内存,该虚拟内存通常大于可用内存。例如,在一个 4GB 内存的系统中,每个进程都假设它拥有所有可用的内存。
C++内存布局
C++内存布局(图片作者提供)
这是我们的应用程序的虚拟内存的布局。在这篇文章中,我们对堆段感兴趣,堆段是我们用来动态分配内存的段。
Text/Code 是存储代码指令的地方,data/BSS 是存储全局数据(已初始化/未初始化)的地方,stack 是用于管理函数调用和局部变量的调用栈。
操作系统如何管理堆?
不同的操作系统管理堆内存的方式不同,为了让本文直观地理解堆内存的管理方式,我们假设有一个类似 unix 的系统,比如 Linux。
控制程序中断地址以分配/取消分配内存(作者图片)
在 Linux 中,我们可以通过调整程序中断来分配/释放内存,程序中断是当前的堆限制。用户空间应用程序可以通过使用 unistd.h 中包含的系统调用 brk()和 s brk()来调整它,有关详细信息,请参见手册页。
不建议以这种方式手动管理内存,因为这样容易出错。我们拥有的第一级抽象是由 C 运行时提供的内存分配库,malloc()系列。
动态内存分配
与直接调用系统调用相比,C 标准库提供了一种更方便的分配/释放内存的方式。它提供:
- malloc():根据给定的大小分配内存
- free():释放以前分配的内存
- realloc():调整先前分配的内存大小
- calloc():为对象数组分配内存
使用这种方法不容易出错,因为应用程序不需要知道当前的堆限制。它所要做的就是通过传入大小来请求内存块,一旦完成了对内存的处理,就通过调用 free()来请求释放内存。
int *ptr = (int *) malloc(sizeof(int));
free(ptr);
malloc()系列 API 使用 brk()、sbrk()和 mmap()系统调用来管理内存。这是抽象的第一层。
malloc()是如何工作的?
实现细节可能因编译器和操作系统而异,但这里我们将概述 malloc()所做的内存管理。
在内部,malloc()通过在应用程序请求的每个内存块中添加元数据来管理内存。出于本文的目的,我们假设它的元数据中有两条信息:
- 大小
- 分配状态(使用中/空闲)
Malloc 内存块(作者图片)
当您调用 malloc()、realloc()或 calloc()时,它会在内存中搜索符合您所请求大小的空闲区域。
malloc 示例(图片由作者提供)
例如,在上图中,可用块显示为蓝色。如果大小合适或更小,块将被重用,否则如果内存中的空闲区域在系统中仍然可用,malloc()将通过调用 brk()、sbrk()或 mmap()系统调用来分配新的内存。
分配新块(图片由作者提供)
如果系统中没有可用的内存,malloc()将失败并返回 NULL。
存储器分配
当你想分配内存块的时候,在引擎盖下发生的是一个搜索。有多种策略,例如:
- First-fit:首先遇到的 fit 内存块
- 下一个 fit:第二个遇到的 fit 内存块
- 最合适的:就尺寸而言最合适的
并非在所有情况下,我们请求的大小都与可用的空闲块匹配,大多数情况下,只有部分可用的空闲块会被使用,并且这些块会被拆分。
蓝色的可用区块(图片由作者提供)
以红色分配的内存(图片由作者提供)
内存释放
当您释放内存时,它不会返回给系统。只有其元数据被改变以反映其现在未被使用的状态。
当内存被释放时,可能发生的一件事是空闲块合并。相邻的空闲块可以合并成一个更大的块,用于更大的请求。
如果当前我们在中间有一个小的空闲块,如下图所示:
当前状态(作者图片)
我们解放了最后一个街区:
另一个块被释放(作者图片)
它们将结合形成更大的块:
最终模块(图片由作者提供)
内存重新分配
正如您可能已经猜到的,内存重新分配,即当我们调用 realloc()时,只是分配内存+将现有数据复制到新分配的区域。
内存碎片
内存碎片是指 小内存块 在较大内存块之间分配的情况。这种情况会导致系统无法分配内存,即使大部分区域可能未分配。
下图说明了这种情况:
全部免费(图片由作者提供)
我们分配 3 个数据块、1 个数据块和 8 个数据块:
所有已分配(图片由作者提供)
然后,我们释放 3 个块和 8 个块:
释放两块内存(图片由作者提供)
现在,我们想要分配 9 个块,尽管我们总共有 11 个空闲块,但是它们都是碎片,还是失败了。
当您的程序在运行时频繁地在堆上分配/释放小对象时,很可能会发生这种情况。
C++动态内存分配
现在我们已经看到了系统中的第一级抽象,我们可以看到 C++提供的下一级抽象。
新建和删除运算符
在 C++中,当我们想从自由存储(或者我们可以称之为堆)中分配内存时,我们使用 new 操作符。
int *ptr = new int;
为了解除分配,我们使用了删除操作符。
delete ptr;
与 C 编程语言中的 malloc()相比,不同之处在于新的操作符做了两件事:
- 分配内存(可能通过调用 malloc())
- 通过调用对象的构造函数来构造对象
类似地, delete 操作符做两件事:
- 通过调用对象的析构函数销毁对象
- 释放内存(可能通过调用 free())
以下代码显示了这一点:
C++中的新建和删除操作符(由作者编写代码)
为了分配内存和构造对象数组,我们使用:
MyData *ptr = new MyData[3]{1, 2, 3};
为了销毁和解除分配,我们使用:
delete[] ptr;
如果我们已经分配了内存块,并且只想构造一个对象,我们可以使用所谓的放置 new 。
typename std::aligned_storage<sizeof(MyData), alignof(MyData)>::type data;
MyData *ptr = new(&data) MyData(2);
第一行将分配在堆栈上存储 MyData 对象所需的内存(假设这些代码行在一个函数中),第二行将在该位置构造对象,而不分配新的内存。这里要小心,因为我们不应该在 ptr 上调用 delete 。
标准::分配器
如你所知 STL 容器如 std::vector,std::deque 等。内部动态分配内存。它们允许您使用自己的内存分配器对象,但是通常情况下,我们使用默认的对象 std::allocator。
他们不使用 new 和 delete 操作符的原因是他们想分别分配内存和创建对象。例如,std::vector 通过将当前大小增加一倍来动态增加内存以优化速度,更多详细信息请参见我的另一篇文章。
我们可以认为 std::allocator 会调用 malloc(),尽管它可能会做一些其他的事情,比如为优化预分配内存。
智能指针
到目前为止,我们所看到的一切都没有解决手动内存管理的问题,分配和释放内存的责任在于开发人员。众所周知,手动内存管理会导致如下问题:
- 内存泄漏,当我们忘记释放内存时
- 崩溃/未定义的行为,当我们试图释放已被释放或双释放的内存时
- 当我们试图访问已经释放的内存块时,出现崩溃/未定义的行为
由于性能和便利性的权衡,C++没有隐式的垃圾收集器来自动管理内存。但它在智能指针中有一个显式的垃圾收集器,当对象超出范围或没有其他对象引用它时,它会自动分配内存和释放内存。
它们是:
- 管理不可复制的另一类型指针的对象(唯一的)
- std::shared_ptr
类似于 unique_ptr,但是可以通过使用引用计数来共享所有权 - std::weak_ptr
一个不拥有的对象,它拥有对指针的引用,但不拥有它
大多数情况下,您应该使用智能指针,并忘记何时释放内存。我们可以在将来的另一篇文章中讨论细节。
其他内存管理库
在内存管理中,我们可能还需要考虑其他一些事情,比如减少使用内存池分配/释放内存时的开销,或者在某些情况下,我们可能需要特别注意小对象分配。我们将在以后的文章中讨论它们。
摘要
关于如何在 C++中分配/释放内存,有多个抽象层次。了解它们非常重要,因为我们不仅知道应该使用哪个级别,还知道在我们的应用程序中可能会出现什么问题。下图说明了不同级别的 API。
C++中不同级别的内存管理(图片由作者提供)
https://debby-nirwan.medium.com/subscribe
C++:可选对象,可能包含另一个对象
使用第一原则思维来学习更好地编码以处理我们的数据。了解可选对象能解决什么问题,分解它,并重新构建它
穆罕默德·拉赫马尼在 Unsplash 上的照片
概观
从 C++17 开始,C++标准库提供了 std::optional ,这是一个管理可选包含值的类模板。可选的类型或者有时也叫可能类型代表一个可选值的封装。
在这篇文章中,我将介绍 std::optional 解决的问题,描述解决这些问题的需求,最后构建一个简单版本的可选类来演示它是如何工作的。
可选对象解决的问题
在我们代码中的一些场景中,我们会面临这样的情况:我们希望从函数中返回一个可选对象,或者在函数中接受可选参数。例如,在下面的代码中,我们希望返回一个只有在满足某些条件时才可用的数据对象。
另一个例子是当我们想设置数据,但有时他们不可用,我们希望函数检查和决定。
如果不使用像 std::optional 这样的类模板来包装数据对象,在 C++17 之前,我们可以使用以下技术。
用一个标志将我们的数据包装在一个结构/类中
我们可以创建以下结构, OptionalData :
我们是这样调用这个函数的:
这种技术不仅增加了大小开销(bool + padding),还增加了构造数据对象的时间开销,当数据对象无效时,这是不必要的。
使用指针包装我们的数据
为了避免在不需要的时候构造数据对象,我们可以使用智能指针,比如 std::unique_ptr 来包装我们的数据。当条件不满足时,我们可以返回 nullptr 。
在呼叫者方面,它看起来是这样的:
与前一种技术相比,这种技术要好得多,但是代码不是很有表现力,因为为此目的使用指针不能很好地解释函数的意图。
返回多个值
从 C++11 开始,我们可以使用 std::tuple 返回多个值,或者在这种情况下我们想返回两个值,可以使用 std::pair 。这可能是最常用的技术。
在呼叫端,它看起来是这样的:
从语义上来说,这种技术与我们的第一种技术相同,在第一种技术中,我们将数据对象包装在一个结构中。因此,它遭受同样的问题,此外,程序员必须记住是否使用“第一”或“第二”。
传入指针并返回布尔状态
我们也可以使用类似 C 的技术,传递一个指针并返回一个布尔值。
在呼叫端,它看起来是这样的:
我们不仅在这里使用了可能被认为是不安全的原始指针,而且尽管它是有效的并且总是需要构造数据对象,但是它的表达性较差。
问题
我们现在可以列出上述各种技术存在的问题,如下所示。
- 他们没有清楚地传达意图。我们的目的不是获取一个指针或一对对象,而是希望在数据对象可用时可以选择获取它
- 其中一些不必要地构造了数据对象
- 对于类似 C 的技术,它是不安全的,尤其是如果指针是从空闲存储/堆中分配的
我们如何解决这些问题?
从上面列出的问题来看,这些是我们的需求。
- 可选对象必须包含真实对象
- 包含的对象应该在可选对象内(不是动态分配的)
- 可选对象可能为空
- 所包含的对象可以稍后设置
- 包含的对象可能在可选对象之前被销毁
包含另一个对象的可选对象(图片由作者提供)
编写简单的可选对象
我们构建可选对象来理解 std::optional 是如何工作的。我们省略了大部分接口,把注意力集中在存储对象的主要内容上。
第一个要求是包含任何对象,并有一个标志来检查它是否为空。
但是这和我们上面的第一个技术是一样的,只是现在它是一个类模板。我们希望可选对象在默认情况下不构造包含的对象。实现它的一种方法是使用 std::aligned_storage 为包含的对象保留空间。
这里发生的情况是,被包含的类型没有被存储,但是当我们构造可选对象时,存储它所必需的空间被保留。
可选对象内存布局(图片由作者提供)
当我们构造一个空的可选对象时,我们只初始化这两个对象。为了初始化包含的对象,我们使用放置 new ,即使用 new 运算符在现有位置构造一个对象。
另一种方法是使用匿名联合,这可能是一种更好、更干净的方法。
下一个要求是,我们可能希望在销毁可选对象之前销毁包含的对象,为此我们添加了 reset()函数。
我们可能还想稍后设置包含的对象,如下例所示:
为此,我们可以实现复制赋值和移动赋值操作符。
我们可以用类似的方式实现移动赋值操作符。通过调用可选<数据>(常量数据& t) 构造器,然后调用复制赋值操作符,首先将数据对象隐式转换为可选<数据> 类型。
其余的接口
为了获得包含的对象,我们可以很容易地实现 value() 函数,如果包含的对象存在,则返回该对象,否则抛出异常。
其余的接口可以很容易地实现,在这篇文章中没有全部展示,因为它可能会变得太长。可以看这个页面看其他界面。
现有 std::optional 的其他改进
可以对现有的 std::optional 进行进一步的改进。在某些情况下,我们可能希望根据可选对象的状态调用其他函数。
为此,根据此页面,C++23 将添加以下内容:
- and_then(f)
如果返回的可选对象包含另一个对象,则执行可调用的‘f’可以返回任何类型,否则返回空的可选对象。 - transform(f)
如果返回的可选对象包含另一个对象,则执行可调用的‘f’返回可选的类型,否则返回空的可选对象。 - or_else(f)
如果返回的可选对象为空,则执行可调用的‘f’返回可选类型,否则返回可选对象。
它们被称为一元函数,其工作是抽象出样板代码,我们可以以更简单、更易读的形式修改上面的代码。
Python 中的类似功能
您可能已经意识到,在 Python 中,我们可以通过不返回任何对象来轻松实现可选对象。
在 C++中,我们必须使用 C++标准库中的 std::optional 来实现这一点。
摘要
关键要点是:
- 我们使用 std::optional 使我们的代码更具表现力
- std::optional 包含对象本身,这取决于它的存储位置(堆栈/数据/堆)
- std::optional 复制包含的对象
- 一元函数将被添加到 C++23 中,通过消除编写样板代码的需要来改进我们代码中的抽象
https://debby-nirwan.medium.com/subscribe
C++类型擦除:包装任何类型
了解如何用 C++编写类中任何类型的包装器,以提高代码的抽象层次。
介绍
我们从泛型编程、面向对象编程和 Duck Typing 概念的一些基础知识开始,以理解这种技术试图解决什么问题。我们将在这篇文章的最后一步一步地介绍细节。
通用编程
泛型编程是指一种编程风格,其中函数或算法被编写为接受不同类型,而不是单一类型。在 C++中,你可以用模板来实现这一点。
使用模板,我们可以要求编译器根据代码中使用的类型为我们生成函数/类。这有助于我们避免多次编写类似的代码块,以保持代码整洁。这里有一个简单的例子:
我们可以如下创建一个模板,而不是编写上面的两个函数。
只有在我们需要的时候,编译器才会为我们生成函数。例如,当我们将这些调用添加到我们的 main() 函数中时:
编译器将生成这些函数:
统一界面
一些基本的编程原则包括“不要重复自己— DRY”,这是为了避免我们的代码中出现重复。方法之一是创建一个统一的接口。
为了更好地理解这个想法,让我们看一个例子。假设我们想要创建一个函数,它接收任何具有 Id 的类型,我们可以通过调用我们想要打印的 GetId() 函数来获得这个类型,让我们调用我们的函数 PrintId() 。
假设我们有如下两个不同的类型和一个自由函数,我们也可以添加 lambdas。
简单的解决方案是为我们想要打印的每种类型编写函数重载:
下面是我们调用它们的方法:
第三个函数可以处理自由函数和无捕获 lambdas。它对无捕获 lambdas 有效,因为它们衰减成函数指针。
这里我们想要解决的问题是,我们想要一个能够接受上述所有类型的函数。
多态性
首先想到的是使用继承,这是 OOP 中的一个概念。我们可以创建一个接口,抽象基类 ABC,并让对象继承或实现它。
并且只实现了一个 PrintId() 函数:
显然,当我们传递不同的类型时,比如函数指针,它就不起作用了。
模板
说到统一接口,还有一个来自 Python 编程语言的概念叫做 鸭子打字 。这基本上意味着我们不需要知道对象的类型,只要它支持我们需要的行为。在我们的例子中,我们需要一个不接受任何东西并返回一个 int 的行为/函数, *int(behavior)() 。
在 C++中,我们可以使用模板来实现同样的事情。Python 的不同之处在于,Python 中的检查是在运行时进行的,而 C++是在编译时进行的。
现在, Object1 和 Object2 不需要相互关联,只要有一个名为 GetId 的函数就可以了。在引擎盖下,我们实际上有两个不同的函数(参见关于泛型编程的第一部分)。
但是,我们仍然不能调用自由函数和 lambdas,因为它们没有一个名为 GetId() 的函数。
使用仿函数使其更通用
我们可以通过使用仿函数来改变我们的类和 PrintId() 函数,使其更加通用。如果你需要更多的细节,请阅读我关于 C++ Lambda 的帖子。
有了这个变化,所有类型都可以工作了。
在引擎盖下,我们有许多由编译器生成的函数,它们是:
第一个 lambda 通过在前面添加“+”运算符隐式转换为函数指针。
现在看起来我们有了解决方案,但是我们还有一个问题要解决。
如果我们想把对象存储到一个数组中呢?
用相同的行为包装任何类型
在我们代码的某些场景中,我们希望将可调用对象存储到一个数组或其他容器中。就我们目前的实现而言,这是不可能的,因为我们没有 STL 容器所需的单一类型,比如 std::vector 。举一个具体的例子,如果我们将 PrintId() 函数改为:
我们将会有编译错误,因为我们还没有实现 ObjectWrapper 类。
创建任何类型的包装
为了解决这个问题,我们需要一个单独的类来包装所有不同类型的对象。这就是我们在 C++中使用类型擦除技术的地方。让我们一步一步地构建我们的包装器。我们的包装类必须:
公开公共接口
我们的包装器首先需要提供统一的接口,在我们的例子中,它是int( behavior)()*。
复制传递的对象
我们的包装类必须管理对象的生存期,以避免使用悬空引用或指针。另一个考虑是对象的大小可能相当大,所以我们需要将它们存储在空闲存储/堆中。我们选择使用智能指针。为此我们需要一个新的类型,我们称之为 ObjectBase 。
多态性
现在我们有了一个名为 ObjectBase 的新类型,我们的目标是包装任何类型。解决方案是使用多态性,我们将 ObjectBase 作为一个接口,并从编译器那里获得帮助来创建继承它的子类。
子类必须是模板类,这样编译器才能生成它们。
提供一个接受不同类型的构造函数(显然)
最后一步是为我们的构造函数创建一个模板函数来接受不同的类型。
形象化
为了帮助更好地理解它,用 UML 图可视化我们的包装类是很好的。我们现在可以包装支持我们通用接口的不同类型, *int(behavior)() 。
我们也可以将它们存储在一个向量中:
类图(图片由作者提供)
实际上,我们有 5 个不同的类实现了 ObjectBase 接口。当我们调用 operator()时,会发生以下情况:
当我们调用 operator()时的事件序列(图片由作者提供)
我们可以看到有两个额外的函数调用,这是包装对象的成本。运行时发生的另一个开销是由我们的虚函数引起的动态分派。
这种技术的真实例子
现在,我们已经看到了用相同的行为包装任何类型的技术,我们可能想知道在什么情况下使用这种技术。您可能在代码中经常使用的两个示例是:
- std::function
一个通用多态函数包装器。 - std::any
任何可复制构造类型的单个值的类型安全容器。
摘要
在这篇文章中,我们将讨论通过使用 C++中的类型擦除技术来提高代码的抽象层次。通过结合 OOP 和编译时 duck typing——c++中的模板,我们可以创建存储任何类型的包装器。
了解一些库(比如 std::function)是如何实现的,对于避免不必要的使用非常重要。例如,我们可能不希望在我们的算法中调用 std::function 对象一百万次,因为上面描述的函数调用和动态分派的开销。
https://debby-nirwan.medium.com/subscribe
在开始我的第一份数据科学工作之前,我希望知道的 5 件关键事情
在你的第一份数据科学工作中,知道这五件关键的事情是很有价值的
到今天为止,我已经完成了作为数据科学家的第一个月的工作。至少可以说,这是一个陡峭的学习曲线,但也是最有收获和最令人兴奋的经历之一!
然而,在这几个星期的介绍中,我不得不迅速掌握一些我以前不了解或了解非常有限的基本技术。在这篇文章中,我希望揭示一些工具,这些工具将帮助新的数据科学家从事他们的第一份工作。
面向对象编程
您可能会使用 Python,它本身就是一种面向对象编程(OOP)语言。这种编码范式在现实生活的数据科学问题中非常有用,在这些问题中,您通常有非常大的数据集和包含数千行代码的笔记本。与典型的过程式编程风格相比,使用 OOP 有助于精简脚本,并为您的程序提供更清晰的结构。事实上,行业中的大多数代码都是使用这种思想编写的,所有的公共库和包也是如此。
我对 OOP 的经验有限,我真希望在开始工作之前,我能用这种范式多练习编码。我对任何初露头角的数据科学家的建议是学习 OOP,比如类、自我、固有等,然后尝试使用这种范式编写一些基本的机器学习算法。网上有很多教程可以让你从不同的人以不同的方式解释 OOP 开始,所以会有一个解释给你!
Git 和 GitHub
“你是 GitHub 的头吗?”
“是啊!”
“你知道怎么用吗?”
“不尽然”
这是我关于 Git 和 GitHub 的第一次对话。据我所知,每个公司都使用 GitHub 做这样或那样的事情,它是任何技术专业人员的必备工具和技能。
我以前用过 GitHub,但只是作为作品集来展示我的作品。然而,Git 和 GitHub 远不止于此,它有着非常有用的功能,我现在仍然习惯于它。
对于那些可能不知道的人来说,Git 和 GitHub 是一个版本控制系统,它简化了编码项目的结构化和管理。还有其他版本控制系统,但 GitHub 是目前的市场领导者。
与 OOP 类似,学习 Git 和 GitHub 相当简单,有大量的在线资源供您探索。学习起来也相当简单,但像任何事情一样,要精通它需要练习。我建议学习基本原理,如推、拉、合并、分支等。
命令行/终端
尽管我们不是软件工程师或开发人员,但数据科学家确实在某些任务中半频繁地使用命令行。很大一部分数据科学家没有计算机科学背景,因此他们使用终端或命令行的经验可能有限。
同样,与 Git 和 OOP 一样,一个简单的教程可以涵盖数据科学家会使用的大部分功能。所以学习一些东西,比如编译、安装包、改变目录等等。这些都是非常琐碎的命令,但我认为任何技术专业人员都应该知道这一点,并能自如地使用它。
模特不是一切
实现最新的机器学习算法通常是项目中最令人兴奋的部分,也是大多数人进入数据科学的原因。我记得我曾经花几个小时来微调我的算法,尽可能地为我的模型挤出最大的性能。
然而,在工业中,这并不总是首选的方法。对于模型表现不佳的原因,最常见的解决方案是您正在训练的数据的质量、类型和大小。您可能听说过数据社区中新出现的“以数据为中心”的概念,它专注于改进数据以改进模型。这在工业上变得非常丰富。
其理念是关注数据的来源、质量,并改进特征工程流程以生成更好的模型。因此,确保您理解甚至将您的学习重点放在项目中的数据预处理上。
我犯的错误是学习了所有关于 ML 算法的知识,这很好,但是忽略了在预处理步骤上投入大量时间。因此,确保你在数据方面和 ML 建模方面都同样关注你的学习,这样你就能精通这两方面。
了解和学习你的行业
你可能是世界上最有技术天赋的数据科学家,但是如果你对你的业务领域没有概念,你的工作将不会有多大价值。数据科学家的工作是回答业务问题并提供宝贵的见解。这意味着你必须知道你的行业是如何运作的,并跟上它的发展。
这在你工作前很难了解,因为你可能不知道你将在哪个业务领域工作。然而,当你了解你的行业时,我建议你每天花大约半个小时阅读它最近的新闻和发展。即使只是一个简单的维基百科潜水也会让你受益匪浅,我注意到它真的在我的项目中帮助了我。值得一提的是,每天在那里工作,你会通过简单的潜移默化学到很多东西。然而,加速这一进程并无坏处。
然而,更普遍的是,只听和阅读新闻,因为这将提高你对一切的知识,使你成为一个更全面的专业人士!
结论
每个数据科学工作都是不同的,工具也因公司和行业而异。我相信上面列出的五个主题是必不可少的,无论你最终将在哪里工作,它们都会让你受益。
和我联系!
- 要在媒体上阅读无限的故事,请务必在此注册!T3💜
- 当我在这里发布注册邮件通知时,可以获得更新! 😀
- 领英 👔
- 推特 🖊
- github🖥
- https://www.kaggle.com/egorphysics🏅
(所有表情符号由 OpenMoji 设计——开源表情符号和图标项目。许可证: CC BY-SA 4.0
用 Python 计算峰度(有例子)
在本教程中,我们将探讨如何在 Python 中计算峰度。
https://stats . stack exchange . com/questions/84158/分布的峰度如何与密度的几何形状相关
目录:
- 介绍
- 什么是峰度?
- 如何计算峰度?
- Python 中如何计算峰度?
- 结论
介绍
峰度主要是描述概率分布形状的一种度量,特别是它的“尾部”。
计算的统计值评估给定概率分布的尾部与正态分布相比有多厚或多薄。
其中偏斜度关注于根据极值区分分布的尾部(或者仅仅是尾部的对称性),峰度测量在任何一个尾部中是否有极值(或者仅仅是尾部是重还是轻)。
为了继续学习本教程,我们需要以下 Python 库:scipy。
如果您没有安装它,请打开“命令提示符”(在 Windows 上)并使用以下代码安装它:
pip install scipy
什么是峰度?
在统计学中,峰度是一个概率分布的相对峰值的度量,或者说它的尾部有多重或多轻。峰度值描述了给定概率分布的尾部与正态分布的不同程度。
峰度可以取几个值:
- 正的过度峰度-当(峰度-3)给出的过度峰度为正时,则分布有一个尖锐的峰,称为细峰分布。
- 负的过度峰度-当(峰度-3)给出的过度峰度为负时,则该分布具有平坦的峰值,并被称为平峰分布。
- 零超额峰度-当(峰度-3)给出的超额峰度为零时,则分布遵循正态分布,也称为中峰度分布。
下面以表格形式总结了上面提到的内容:
如何计算峰度?
峰度的度量被计算为分布的第四个标准化矩。
听起来有点复杂?按照下面的步骤,对计算有一个完整的理解。
分布的第k 个力矩可以计算为:
如前所述,偏斜度是分布的四阶矩,可计算如下:
并且知道分布的二阶矩是它的方差,我们可以将上面的等式简化为:
其中:
举例:
上面是很多公式。为了使这一切成为一个更好理解的概念,让我们来看一个例子!
请考虑以下代表学生考试成绩的 10 个数字序列:
X = [55,78,65,98,97,60,67,65,83,65]
计算 x 的平均值,我们得到:x̄=73.3.
求解 m_4:
求解 m_2:
求解 K :
Python 中如何计算峰度?
在这一节中,我们将通过一个例子来计算 Python 中的峰度。
首先,让我们创建一个类似于上一部分的数字列表:
为了计算偏度的 Fisher-Pearson 相关性,我们需要 scipy.stats.kurtosis 函数:
我们应该得到:
2.0453729382893178
注意:在上面的代码中设置 fisher=False 会计算 Pearson 的峰度定义,其中正态分布的峰度值= 3。
我们发现,对于给定的数列,峰度值约为 2.05,而过度峰度值约为-0.95。这表明我们有一个比正态分布更厚更平的分布。
结论
在本文中,我们讨论了如何使用 scipy 库在 Python 中计算一组数字的峰度。
如果你有任何问题或对一些编辑有建议,请随时在下面留下评论,并查看更多我的统计文章。
原载于 2021 年 9 月 2 日 https://pyshark.comhttps://pyshark.com/kurtosis-in-python/。
用牛顿-拉夫逊法计算最大似然估计量
卢卡斯·布拉塞克在 Unsplash 上的照片
使用此方法可以帮助您计算模型中任何估计量的最大似然估计量(MLE)。
动机
在统计建模中,我们必须计算估计量来确定你的模型的方程。问题是,估计量本身是很难计算的,尤其是当它涉及到一些分布,如贝塔分布、伽玛分布,甚至 Gompertz 分布。
最大似然估计(MLE)是计算这些分布的估计量的许多方法之一。在这篇文章中,我会给你一些例子,用牛顿-拉夫逊法计算最大似然估计。
概念:MLE
首先,我们考虑
作为具有概率分布函数(PDF)的独立同分布(iid)随机变量
其中参数θ未知。该方法的基础是由下式给出的似然函数
该函数的对数,即对数似然函数,表示为
为了确定 MLE,我们确定对数似然函数的临界值;也就是说,最大似然法解了这个方程
概念:牛顿-拉夫逊法
牛顿-拉夫森法是计算函数 f 的根的迭代程序。在这种方法中,我们希望通过计算来逼近函数的根
其中 x_{n+1}是第(n+1)次迭代。这种方法的目标是使近似结果尽可能接近精确结果(即函数的根)。
综合起来:计算最大似然估计的牛顿-拉夫逊方法
牛顿-拉夫逊方法可用于生成收敛于最大似然估计的序列。如果我们假设θ为一个 k × 1 向量,我们可以迭代
其中 l’(θ) 是对数似然函数的梯度向量, l’'(θ) 是对数似然函数的海森。
R 中的实现
对于实现,假设我们有
而我们想用 MLE 来估计μ。我们知道泊松分布的 PDF 为
似然函数可以写成如下形式。
根据上面的似然函数,我们可以将对数似然函数表示如下。
在 R 中,我们可以通过取 PDF 的对数来简单地写出对数似然函数,如下所示。
#MLE Poisson
#PDF : f(x|mu) = (exp(-mu)*(mu^(x))/factorial(x))#mu=t
loglik=expression(log((exp(-t)*(t^(x))/factorial(x))))
dbt=D(loglik,"t")
dbtt=D(dbt,"t")
然后,我们通过分别运行dbt=D(loglik,"t")
和dbtt=D(dbt,"t")
来计算对数似然函数对μ的一阶和二阶偏导数(然后μ对第二个偏导数)。结果如下。
dbt=(exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x)/(exp(-t) *
(t^(x))/factorial(x))dbtt=(exp(-t) * (t^(((x) - 1) - 1) * ((x) - 1) * (x)) - exp(-t) *
(t^((x) - 1) * (x)) - (exp(-t) * (t^((x) - 1) * (x)) - exp(-t) *
(t^(x))))/factorial(x)/(exp(-t) * (t^(x))/factorial(x)) -
(exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x) *
((exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x))/(exp(-t) *
(t^(x))/factorial(x))^2
然后,我们可以开始在 r 中创建 Newton-Raphson 方法函数。首先,我们生成泊松分布的随机数作为我们用来计算 MLE 的数据。对于这个函数,我们需要如下这些参数。
n
对于泊松分布的生成数据的数量,t
为μ值,且iter
为牛顿-拉夫逊法的迭代次数。
由于均值的泊松分布的 MLE 是μ ,那么我们可以如下编写函数的第一行代码。
x=rpois(n,t)
x.mean=mean(x)
par.hat=matrix(0,1,1)
estimate=c(rep(NULL,iter+1))
difference=c(rep(NULL,iter+1))
estimate[1]=t
difference[1]=abs(t-x.mean)
然后,我们创建循环函数来计算偏导数的和(这就是为什么我们只需要将对数似然函数的 PDF 的对数写在 R 中)、梯度向量、Hessian 矩阵和 MLE 近似值,如下所示。
for(i in 1:iter)
{
#First partial derivative of log-likelihood function with respect to mu
dbt=(exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x)/(exp(-t) *
(t^(x))/factorial(x))
#Second partial derivative of log-likelihood function with respect to mu, then mu
dbtt=(exp(-t) * (t^(((x) - 1) - 1) * ((x) - 1) * (x)) - exp(-t) *
(t^((x) - 1) * (x)) - (exp(-t) * (t^((x) - 1) * (x)) - exp(-t) *
(t^(x))))/factorial(x)/(exp(-t) * (t^(x))/factorial(x)) -
(exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x) *
((exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x))/(exp(-t) *
(t^(x))/factorial(x))^2
sdbt=sum(dbt)
sdbtt=sum(dbtt)
#hessian matrix
h=matrix(sdbtt,1,1)
#gradient vector
g=matrix(sdbt,1,1)
#parameter
par=matrix(t,1,1)
par.hat=par-solve(h)%*%g
t=par.hat[1,]
estimate[i+1]=t
difference[i+1]=t-x.mean
}
当迭代达到极限时,我们需要计算每次迭代中最大似然估计的实际值和近似值之差,以评估牛顿-拉夫逊方法计算最大似然估计的性能。规则很简单:差异越小,性能越好。我们可以把它写成我们函数的最后几行代码,如下所示。
tabel=data.frame(estimate,difference)
rownames(tabel)=(c("Initiation",1:iter))
print(x)
print(tabel)
cat("The real MLE value for mu is :",x.mean,"\n")
cat("The approximated MLE value for mu is",t,"\n")
完整的函数如下所示。
nr.poi=function(n,t,iter=100)
{
x=rpois(n,t)
x.mean=mean(x)
par.hat=matrix(0,1,1)
estimate=c(rep(NULL,iter+1))
difference=c(rep(NULL,iter+1))
estimate[1]=t
difference[1]=abs(t-x.mean)
for(i in 1:iter)
{
#First partial derivative of log-likelihood function with respect to mu
dbt=(exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x)/(exp(-t) *
(t^(x))/factorial(x))
#Second partial derivative of log-likelihood function with respect to mu, then mu
dbtt=(exp(-t) * (t^(((x) - 1) - 1) * ((x) - 1) * (x)) - exp(-t) *
(t^((x) - 1) * (x)) - (exp(-t) * (t^((x) - 1) * (x)) - exp(-t) *
(t^(x))))/factorial(x)/(exp(-t) * (t^(x))/factorial(x)) -
(exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x) *
((exp(-t) * (t^((x) - 1) * (x)) - exp(-t) * (t^(x)))/factorial(x))/(exp(-t) *
(t^(x))/factorial(x))^2
sdbt=sum(dbt)
sdbtt=sum(dbtt)
#hessian matrix
h=matrix(sdbtt,1,1)
#gradient vector
g=matrix(sdbt,1,1)
#parameter
par=matrix(t,1,1)
par.hat=par-solve(h)%*%g
t=par.hat[1,]
estimate[i+1]=t
difference[i+1]=t-x.mean
}
tabel=data.frame(estimate,difference)
rownames(tabel)=(c("Initiation",1:iter))
print(x)
print(tabel)
cat("The real MLE value for mu is :",x.mean,"\n")
cat("The approximated MLE value for mu is",t,"\n")
}
对于这个函数实现的例子,假设我们想要计算 100 个泊松分布数据的最大似然估计,其平均值为 5。通过使用上面已经写好的牛顿-拉夫逊法函数,迭代次数为 5,结果如下。
> nr.poi(100,5,5)
[1] 5 4 6 9 7 8 7 2 9 4 5 6 10 1 4 8 5 7 4 3 6 3 4 4 4 7 6 6 3 6 5 5 6 4 5 5 9 5
[39] 5 3 5 6 5 8 5 3 3 12 6 5 3 4 8 5 4 5 7 8 8 5 7 2 8 3 6 4 2 3 7 5 3 4 6 5 2 6
[77] 3 3 5 4 8 2 4 7 6 5 4 3 4 7 3 4 6 6 4 7 4 4 14 4
estimate difference
Initiation 5.000000 2.400000e-01
1 5.229008 -1.099237e-02
2 5.239977 -2.305956e-05
3 5.240000 -1.014779e-10
4 5.240000 0.000000e+00
5 5.240000 0.000000e+00
The real MLE value for mu is : 5.24
The approximated MLE value for mu is 5.24
作者图片
从上面的结果中,我们可以看到牛顿-拉夫逊法 MLE 产生的结果与真实的 MLE 值相同。请注意,该方法使用生成的数据,因此每次运行的结果可能不同。
结论
MLE 可以帮助我们根据对数似然函数计算估计量。我们可以用牛顿-拉夫逊法在数值上逼近极大似然估计的结果。
现在我们在这里,你可以用牛顿-拉夫逊法,用 R 来计算最大似然。关于这个话题的更多讨论,请随时通过 LinkedIn 联系我。
参考文献:
[1] Robert V. Hogg,Joseph W. McKean 和 Allen T. Craig,数理统计导论,第七版 (2013),培生教育。
[2]阿迪·本-伊斯雷尔,《求解方程组的牛顿-拉夫森法》 (1966),数学分析与应用杂志。
[3]https://book down . org/rdpeng/advstatcomp/newtons-method . html # proof-of-newtons-method
从活动跟踪器的日志中计算指标
计算距离,速度和海拔增益从您的活动跟踪器的日志
弗兰克·布施在 Unsplash 上的照片
介绍
前一篇文章描述了如何从你的活动跟踪器或应用程序导入日志文件。我们还创建了一个热图,显示地图上的轨迹。本文将向您展示如何从 gpx 日志文件中计算一些基本指标,如行驶距离、速度和方位。一些活动记录器将这些信息存储在日志文件中,但大多数不这样做。
计算行驶距离
从日志文件中计算旅行的距离听起来很简单。测井记录由一系列经度/纬度位置组成,因此计算所有时间之间的距离,并将这些时间相加,即可得出行进的距离。但是计算两个经度/纬度位置之间的距离很繁琐,即使是很短的距离。
我们将尝试几种方法来找到速度和准确性之间的平衡。您可能已经猜到,与低精度相比,高精度需要更多的计算。我们将把上一篇文章中的数据框架用于所有活动,并过滤掉一个:
df = df[df.name == 'XXXXX'].reset_index(drop=True)
df = df[['timestamp', 'lat', 'lon', 'elevation']]
在这种情况下,我们根据名称过滤一个活动,但也可以使用源文件或开始日期和/或时间。
平坦的地球
用 lon/lat 计算距离需要一些三角学知识。三角函数以弧度作为输入。gpx 文件将经度和纬度存储为度数,因此我们首先添加(第 4–5 行)以弧度表示的经度和纬度。
为每个距离公式创建一个名为dist_xxxx
的方法,计算结果将存储在名为dist_xxxx
的列中。第 14 行循环所有的实现(到目前为止只有一个),第 16 行创建列 an,在第一行存储0
。第 17–19 行调用距离函数,将当前位置和先前位置作为参数。结果存储在适当的列中。第 20 行根据距离公式打印行驶的总距离。
第一个实现假设两点之间的距离是一条直线(勾股定理)。它没有考虑到当你靠近其中一个极点时,经线会彼此靠近。假设这些线平行,经度为 1 度,距离为 110.25 公里。计算很简单,但即使在短距离内,误差也相对较大。结果是 10.8 公里。
flat : 10864.7117
地球是一个球体
一个简单的改进是假设地球是一个完美的球体。当你越靠近两极,两条经线之间的距离就越小。
纬度和经度(来源:维基百科
这个距离取决于所在位置的纬度。在赤道是 110 公里,但在两极是 0 公里。通过将 110 km 乘以纬度的余弦,应用球体形状(0 度的余弦为 1,90 度的余弦为 0)。
活动日志每隔几秒钟存储一次位置。看起来这些距离的误差可能很小,但是总距离变化很大:
flat : 10864.7117
sphere : 8057.7413
差别是惊人的 26%,但这种差别取决于我活动的纬度。如果在赤道附近记录到这种活动,它将接近于 0。根据上面的结果,你可以计算出该活动是在纬度 50 度附近记录的。所以要小心,由于信息量有限,活动的位置从地球上的某个地方缩小到纬度约为 50 度的范围内。
地球是椭球体
那么地球是球体吗?不,它不是,它是一个椭球体。赤道的半径(“水平”)是 6378 公里,从极点到极点的半径(“垂直”)是 6356 公里。
椭圆计算(来源:维基百科)
FCC 已经定义了公式来计算两点之间的距离,其中考虑了这个椭球体形式:
地球的小椭球形对计算的距离有影响:
flat : 10864.7117
sphere : 8057.7413
ellipsoid : 8143.150109
哈弗辛函数
哈弗辛函数是计算球体上两点间距离的另一种方法。最早的查找表形式的实现可以追溯到 1805 年。它是基于哈弗辛的定律,该定律描述了两点之间的距离,该距离基于它们与网格原点一起形成的三角形。哈弗辛函数没有考虑地球的椭球形状。
与其他实现相比的哈弗线距离:
flat : 10864.7117
sphere : 8057.7413
ellipsoid : 8143.1501
haversine : 8126.8135
还有更多像文森特的公式一样的计算方法。但是从这里我们将采用椭球体实现:
df = df[['timestamp', 'lat', 'lon', 'elevation', 'latrad',
'lonrad', 'dist_eucledian']]
df = df.rename(columns={"dist_eucledian": "dist"})
带有距离栏的活动日志(图片由作者提供)
计算速度
所以现在距离有了,就可以计算速度了。速度是距离除以时间,因此我们需要获得两个日志条目之间的时间,并将行驶距离除以该时间(速度单位为 km/h):
那很简单…让我们想象一下结果:
df.speed.plot(figsize=(15,8))
速度随时间的变化(图片由 auhtor 提供)
的确,这好得令人难以置信。该图显示了一个行走活动,速度肯定不是恒定的,但它是可变的吗?不太可能。这是怎么回事?
正如您在最后打印的数据帧中所看到的,每秒钟都会记录一次该位置。平均速度 1.6 米/秒。但是 Garmin 活动追踪器公认的精确度是 3 米。在实践中,它可以更精确,但会发生一些不准确,并且对于 1 米的预期位移,不准确性相对较高。在整个活动过程中,它会工作(见显示的平均速度 5.1 公里/小时),但在这个详细的水平上不是那么好。
为了过滤掉极端情况,我们将实现一个平均速度的策略。我们想要每 30 秒的平均速度。为了确定要平均的行数,我们首先确定数据集中两行之间的平均时间。用 30 除以这个数,我们得到要平均的行数。我们使用 dataframe 的rolling()
函数来创建移动平均值:
速度,移动平均线(图片由作者提供)
这是速度的一个更好的表示。选择 30 秒是基于我的行走习惯。在旅途中,每个地点拍照大约需要 30 秒。使 30 秒的时间间隔更长会使识别这些停车点变得更加困难。
在第一张图中,停车瞬间将速度降至零,30 秒移动平均速度在行程后期降至 1 km/h 或 3 km/h 以下。
让我们看看滚动窗口大小的影响:
df['speed_ma_10'] = df['speed'].rolling(window=10,
min_periods=1, center=True).mean()
df['speed_ma_30'] = df['speed'].rolling(window=30,
min_periods=1, center=True).mean()
df['speed_ma_60'] = df['speed'].rolling(window=60,
min_periods=1, center=True).mean()
df[500:1000][['speed_ma_10', 'speed_ma_30',
'speed_ma_60']].plot(figsize=(15,8))
多个滚动窗口(图片由作者提供)
滚动窗口越小,停止时刻越明显,但窗口越大,停止时刻越不明显。滚动窗口越大,图形越平滑。找到合适的窗口大小是准确性和可读性之间的平衡。对于我的使用,我把它保持在 30 秒,但是你需要根据你的规格来改变它。
请注意,无法使用df.iloc[::30, :]
分割数据帧并在 30 行中保留 1。行驶的距离将是不正确的,因为 31 和 60 之间的最短路径被用于计算速度,从而切断了路径上的所有拐角:
分割数据帧对行驶距离的影响(图片由 authot 提供)
海拔
当确定上升和下降时,同样的问题出现了。由于高程数据的不准确性,分析原始高程数据会导致上升和下降数字较高:
高程数据(图片由作者提供)
总的上升和下降是用每行的高程平均值计算的,并对下降的负数和上升的正数求和:
与海拔高度和活动概况相比,上升和下降的数字较高。通过使用移动平均值平滑与速度相当的高程数据,可以提高这些值。
30 秒移动平均值保持高程数据的形状,并过滤小的差异,正如您在高程数据的子集中看到的,滚动窗口由 30、60 和 120 行组成:
滚动窗口对高程数据的影响(图片由作者提供)
最后的话
使用活动跟踪器跟踪您的活动会产生一个包含位置和高度数据的详细数据文件。记录的数据包含一些(或大量)噪声,使得从数据中得出结论更加困难。在计算距离、速度和高度等指标之前,需要进行一些数据清理。
使用简单的移动平均滤波器来平滑数据。也可以使用更复杂的滤波器,如 FIR 和 Butterworth 。从数学的角度来看,这非常有趣,但解释起来要复杂得多(与移动平均线相比)。过滤后的数据不会有更好的价值。这些滤波器从信号中移除高频噪声,并且在噪声还包含模式时工作得最好。这在我们的活动数据中并非如此。当我们创建一个带有高频噪声的信号时,IFR 滤波器将比移动平均效果更好:
x = np.arange(0, np.pi, 0.05)
y = np.sin(x) + 0.2*np.sin(x*40) + + 0.05*np.sin(x*80)df = pd.DataFrame(y,x, columns=['y'])
df = df.reset_index().rename(columns={'index': 'x'})df['y_ma'] = df['y'].rolling(window=5, min_periods=1).mean()
#FIR filter
h = sg.get_window('triang', 5)
fil = sg.convolve(df.y, h / h.sum())
df['y_fir'] = fil[:len(df)]df.plot('x', ['y', 'y_ma', 'y_fir'], style=['--','-','-'])
IFR 滤波信号比移动平均值更接近原始正弦:
FIR 滤波器在去除高频噪声方面更好(图片由作者提供)
理解你的数据是成功的关键:-)。
在你的活动日志文件中隐藏着更多的数据。我们将在下一篇文章中揭开它们的面纱。
我希望你喜欢这篇文章。要获得更多灵感,请查看我的其他文章:
- 根据你的活动跟踪器的日志创建热图
- 用 Python 删除文本中的个人信息
- Python 中字符串的并排比较
- 使用 Python 实现并行 web 请求
- 所有公共交通工具都通向乌得勒支,而不是罗马
- 使用 OTP 和 QGIS 可视化行程时间
免责声明:本文包含的观点和意见仅归作者所有。