int 还是 uint,这是个问题¶
标题:To Int or to Uint, This is the Question
日期:2025/04/02
作者:Alex Dathskovsky
链接:https://www.youtube.com/watch?v=pnaZ0x9Mmm0
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
好的。我们开始吧。首先,我想说谢谢大家能来并选择我的演讲。这次会议有那么多非常棒、非常棒的演讲,你们选择了这个演讲并在这个大房间里,我感到很惊讶。非常感谢大家能来。简单介绍一下我自己。我是 Alex Dotskovsky。我有超过 17 年的系统编程经验。我做过很多不同的系统,从拯救生命的医疗系统到剥夺生命的安全系统。你知道,两者都得做。我现在在一家叫 Speed Data 的公司工作。我们是一家很棒的初创公司,正在开发大数据领域的下一个重大产品。所以,基本上,我们正在做的是创造一种新的 CPU,一种通用 CPU,它将极大地加速你的分析。也许在明年的 CppCon 上,我终于能拿到芯片,并最终做点关于芯片的事情。我有 LinkedIn。在那里我经常谈论 C++、谜题、人们讨厌的东西。如果你想加入,你可以那样做。如果你能关注我,那将是非常棒的。我开了一个博客,因为 LinkedIn 只是发小帖子,我想发布更多内容并更详细地解释,让人们理解。所以,CppNext。这是我创办的一个网站。我还有 YouTube 频道。我已经有七个视频了。所以,在这个演讲之后,去点赞和订阅吧。我会非常感激的。但不是现在,对吧?
现在我们可以开始讨论 int
还是 uint
了。你们能从这次演讲中期待什么?首先,我想说这不是一个演讲。这将是一次吐槽。我吐槽 C++,以及所有关于 int
和 uint
的规则是多么可怕。而且它不是为人设计的。所以,我们必须理解这些类型,才能真正欣赏它们,并真正理解它们对我们的程序做了什么。你们中有没有人刚开始看这些类型时就觉得,怎么说呢?当你使用它们时,你脑海中首先想到的就是到处都用 int
,对吧?如果你想到整数,请举手。int
。
我不知道这是对还是错,但这是个问题。我们的心态就像从学校开始就倾向于 int
。但这并不总是适合工作的正确类型。所以,我们会讨论这个问题。但我会向你们展示许多、许多陷阱,如果你选择了错误的类型会发生什么。糟糕的事情可能会发生。可能会有未定义行为。有那么多细小、细小的规则,简直令人惊讶,你怎么会在语言中最简单的事情上栽跟头。
那么,我们开始吧。我选择从引用一些“无名之辈”开始。有原因的,为了,抱歉。为了,这里我们有 Bjarne,他在 C++ 中是个匿名人士。没人认识他,对吧?所以 Bjarne 说,整数类型实在太多了。有太多宽松的规则需要理解如何与它们一起工作。他基本上是从所有这些中得出的结论是:保持简单。始终使用有符号类型。他说的对吗?我们拭目以待。
然后我们有 Dale。Dale 是一位非常知名的博主。他在那家媒体工作。他在图形和浮点类型中大量使用整数类型。而他基本上说的是完全相反的东西。他说你不必使用有符号整数。你几乎永远不必使用有符号整数。索引、大小,一切都是无符号的。那么,为什么我们的心理状态说总是用 int
?这并不总是正确的。所以他说,停止使用那个。只使用无符号类型。他说的对吗?我也不太确定。所以,为了让你们理解,这次演讲不会有争议性。你们不会从中得出“只使用这种类型”的结论。这不是重点。重点是让你们理解为正确的整数类型做选择的重要性。如此简单的东西竟然可以如此复杂。
这里有一个免责声明。我将向你们展示的所有内容都将为 x86 编译。每台机器都有不同的指令集架构,工作方式略有不同。我选择 x86 是因为你们大多数人可能都在用 x86,对吧?有人在非 x86 的东西上工作吗?有一些人。好的。但你们大多数人使用 x86。所以,理解你们正在使用的机器对你们大多数人来说将是有益的。而且大部分编译将使用 Clang 12 完成。原因呢?请等待。
#include <stdint.h>
int64_t add_and_devide_s(int64_t a, int64_t b) {
return (a + b) / 2;
}
uint64_t add_and_devide_u(uint64_t a, uint64_t b) {
return (a + b) / 2;
}
为了让我们开始,让我们从一个简单函数的简单例子开始。基本上是两个函数。一个是用于无符号整数。一个是用于有符号整数。这个函数所做的就是接收两个参数。有符号或无符号。但类型相同。它计算 A 和 B 的和。然后除以二。功能相同。类型不同。现在,我想看看举手。谁认为无符号版本会更快?更好的汇编。更快的运行时间。
几乎没人。有符号的。谁认为有符号的会更好?你们认为两者会一样。好的,两个人。有符号的会更好。好的,让我们试着看看。
"add_and_devide_u(unsigned long, unsigned long)":
lea rax, [rdi+rsi]
shr rax
ret
当然,让我们尝试从第一个无符号版本的汇编代码开始看。我们已经可以看到这个汇编相当简单。三行代码。太棒了。对于这次演讲,你们需要稍微理解一点 x86 汇编。但别担心。我会教你们一些汇编。你们首先需要知道的是,x86 机器有寄存器。它们有不同大小的寄存器。它们构建时具有 8 位寄存器、16 位寄存器,一直到 64 位寄存器。这是因为历史原因。他们只是在一个处理器之上构建另一个处理器。所以他们从 8 位开始,发展到 64 位,这是现在的标准。但你们现在的机器里仍然有所有这些。最重要的事情,只是为了理解代码,就是看到以 R 开头的是 64 位寄存器。以 E 开头并有三个字母的是 32 位寄存器。其他东西不会涉及太多,所以你们没问题。如果你们想了解更多,可以看 Dave Sinckel 在 CPP Now 2023 的非常棒的演讲“Under the Hood”。我认为理解这些东西如何工作对每个人都非常有益。现在开始。这个汇编的第一条指令是 LEA
。LEA
是一条指令,意思是加载有效地址。这条指令并不加载有效地址。这条指令基本上用于计算并加速寄存器的运算。所以如你所见,编译器没有使用两条 ADD
指令,而是可以用一条汇编指令生成这条 LEA
指令,包含 RDI
加 RSI
。一条汇编代码行。这很酷。你甚至可以在同一条指令中使用更多寄存器。我这里没有,但你可以。编译器实际上生成了这些东西。下一件事是右移。这里没有除法。是移位。然后我们就返回了。有人能说出为什么是移位而不是除法吗?我们会讨论它,但我想看看你们是否理解。是的。但我们也会理解为什么它更快。
"add_and_devide_s(long, long)":
lea rcx, [rsi + rsi]
mov rax, rcx
shr rax, 63
add rax, rcx
sar rax
ret
好的。现在让我们看看有符号版本。嗒-哒!它更长。而且更慢。不是慢很多。但它有更多的汇编代码行。如果我们开始看它,我们可以看到它用 LEA
做了同样的事情。它仍然加载有效地址。但接着它做了一些奇特的事情。它把刚刚做的和复制了一份到 RAX
。然后它移了 63 位,基本上是从数字中提取最高有效位。然后它把这个最高有效位加到 RAX
上,加到我们在第一行已经完成的和上。然后它使用了算术右移,这很奇怪,对吧?但这里发生了什么?因为编译器知道你在做除以二的操作。编译器知道它可以为你加速。因为除以二和右移是一样的。而右移更快。
同样,我们会明白为什么它更快。我们必须理解。为此,我们必须理解每条指令都是从内存中取出的。并被推入流水线。在那个流水线内部,有一个执行阶段。在那个执行阶段内部,一些执行操作也有流水线。所以我们的流水线内部有两条流水线。你的 CPU 中执行单元的数量是有限的。所以你必须理解,有时你会停顿,因为没有可用的单元。每条指令都有自己的延迟。现在,什么是延迟?这里用一个非常非常简单的定义,延迟是处理器计算你的指令或操作码所需的周期数。当然,如果你有错误、未对齐和缓存未命中,它会变慢。如果是缓存未命中,你必须去访问内存。你会停顿。会花更多时间。如果你有错误,你必须从错误中恢复。所以周期计数会增加。令人惊讶的是,NaN和无穷大不会增加你的延迟。
这里有一个很好的例子说明为什么不选择除法。我们的基准是 ADD
。ADD
是一个非常简单的 ALU 指令。它没有流水线化,因此它只需要一个周期。计算 ADD
需要一个周期。IMUL
是整数类型的乘法。它需要三个周期。同样,这很大程度上取决于你拥有的 CPU,但它大约需要三个周期。除法至少比乘法慢 20 倍,这取决于你的架构。可能更糟。这就是为什么编译器几乎从不选择除法,如果它能选择其他东西而不是除法,它会选择。如果你能帮助编译器并自己做一些事情,那会更好。在这里,我只是想解释一下我之前提到的流水线是如何工作的。所以你有一个流水线。顺便说一下,这是一个超级简化的流水线,现在已经不是这样工作的了。现在 x86 中的流水线比这激进得多。这是个玩笑。但对我们理解流水线如何工作来说,这已经足够好了。在这个简化的流水线中,我们有四个阶段。第一阶段是从内存取指令。第二阶段是解码,理解我们将要使用的寄存器。然后我们有执行部分。然后我们需要写回,因为我们想把结果存到某个地方,对吧?如果一切顺利,所有星星都对齐了,你会有每个周期一条指令的带宽。这有多棒?但通常不会这样工作。所以如果你有一条指令因为某种原因停顿了,例如像这里,它试图访问内存。访问内存需要很多时间。但然后你有那些实际上并不使用我们试图从内存复制的东西的指令。它们无缘无故地被停顿了。然后你就得不到每个周期一条指令了。这是个问题。但好事情是我们的新处理器已经知道如何处理它了。有一种叫做乱序执行的东西。它基本上是说,如果这些指令之间没有冒险,那么你可以把它们一起执行,然后重新排序,让它看起来像是按顺序运行的。这很酷。如果你想了解更多,可以看我去年关于 C++ 内存模型的演讲。如果你想听扩展版本,我在 CPP now 做过一次。我在那里更深入地讲了内存排序。所以编译器和 CPU 对你有好处。它们是你们的朋友。它们试图让事情变得更好,为你运行得更快。你不应该害怕它们。你应该理解它们是如何工作的。希望这次演讲之后,你们能更好地理解它。
让我们回到那个例子。所以,对的。我们知道有延迟,这就是为什么我们选择移位。但在这里我们不得不额外加一个移位。然后我们使用算术右移。为了理解我们为什么这样做,我们需要理解有符号和无符号整数在内存中存储方式的区别。无符号整数表示为模 2。它们只支持正数。溢出是明确定义的。这对我们的演讲非常重要。无符号整数的溢出是明确定义的。范围非常非常大。如你所见,它是 2 的 n 次方。它只能表示零和正数。要表示一个无符号整数,你必须拥有这些位。例如,这里我们有 1, 0, 1, 1, 0。然后你有权重。权重基本上是 2 的位索引次方。然后如果你想用十进制表示它,你必须用位乘以权重。然后把所有东西加起来。这里我们基本上表示的是 22。
溢出的定义就像离散数学里那样。谁还记得离散数学?酷。所以它和无符号整数是一样的。就像看一个时钟。所以如果时钟现在是上午 11 点。我们想给它加 2 小时,时钟会溢出到下午 1 点。这里基本上也是一样。如果我们有 4 位整数,表示范围是 0 到 15。如果我们现在在 15,我们想给它加 2,这是定义好的。我们只会溢出到 1。所以这很好。另一方面,有符号整数,它们支持两种类型。它们也支持负数。它们使用三种不同的方式存储。所以过去是由编译器定义它们如何存储。符号和数值法、反码和补码。我们稍后会讲到 C++ 中发生了什么。溢出被认为是未定义行为。我们不知道它是什么。范围当然是从负数到正数。但能表示的数字更少,因为我们必须有负数,而且有一位已经被占用了。反码。让我带你们回到学校。反码是一种表示负数或有符号整数的非常简单的方法。要做到这一点,你所要做的就是取所有的位。你必须看你看到的数字。你必须把它看作是有符号整数,然后翻转所有的位。反码的问题是,如果你看 7,我们那里有垃圾。所以 7,如果我们翻转所有的位,我们会得到 0。然后我们得到负零。这意味着我们表示的数字更少,而且只表示了一些对任何人都不重要的东西。这是 15 的例子。我们全是 1。我们把它们看作无符号的。然后我们翻转所有位,得到负零,我们无法用它做任何事情。补码几乎一样。几乎一样。但唯一的区别是,我们做同样的位翻转,然后我们给它加 1。因为我们给它加了 1,我们可以表示更多的数字。我们不再有垃圾了。所以如果我们现在看 7,它将是 -1。这是我们想要的表示。如你所见,数字的表示现在看起来更好了。同样,一个简单的 15 例子。我们翻转所有位。我们给这些位加 1。我们得到 -1。简单。这种表示对每个人都更好。
这就是为什么在 C++20 中它终于被标准化了。编译器不能再选择了。较新的编译器必须用补码实现它,这很好。另一件重要的事情要记住,正数的表示方式与它们是无符号时完全一样。所以我们也许能理解为什么我们必须那样做。
但这里重要的部分是 SAR
。SAR
,正如我提到的,是算术右移。所以现在我们必须理解 SAR
和 SHR
之间的区别是什么。逻辑右移和算术右移。逻辑右移所做的是,它把你所有的位向右推,然后最高有效位将是 0。而算术右移做同样的事情,但如果最高有效位是 1,它会保持它为 1。所以算术右移是无符号整数的正确方法。这就是为什么编译器选择了 SAR
而不是 SHL
。
但我们仍然不明白为什么我们必须提取最高有效位,或者基本上是符号位,并把它加到我们之前得到的数字上。然后我们才移位。那么我们为什么需要这样做呢?是的。打断的方式。但我认为这个比较并不真正公平,因为你是在比较两个不同的实现。因为对于无符号整数,功能并不完全相同。因为对于负整数,如果我们除以二,如果它是奇数,它的行为就像我们先加一,然后除以二,对吧?它仍然是一样的。它仍然是整数类型。你仍然需要做一些舍入。我的意思是,例如,如果编译器知道这两个整数是正数,或者它们的和是正数,那么它会生成和…一样的汇编。但它无法知道。我的观点是因为编译器不知道,是因为我们在比较两个不同的函数。我们在比较…不,我不同意那个说法。我们是在比较几乎相同的函数,只是类型不同。是的,这就是为什么你得到不同的汇编代码,但功能是相同的。不,它们的功能是相同的。就像,如果你…就像,你在比较苹果和两个橘子,我认为。不,这是不正确的。我很抱歉,但这不是。它是相同功能,类型不同。它做同样的事情,只是方式略有不同,因为编译器和处理器需要处理不同的东西。抱歉,我必须走了。你稍后可以和我谈。我们继续。那么现在,为什么我们必须做右移并从中提取那个位呢?这再次是因为我之前没有提到的算术右移和逻辑右移的唯一区别是舍入模式不同。逻辑右移是…它向零舍入,而算术右移是向最近值舍入。这就是为什么它需要提取那个位。因为舍入方式不同。为了得到相同的舍入,它必须给它加一。如果是无符号的。因为你需要舍入到不同的位置。
基本上就是这样。你可以理解,即使是最简单的例子,当使用不同类型时,也不像你想象的那么简单。仅仅是不同的整数类型,你就会得到不同的结果。逻辑是相同的。你得到的代码是不同的。如果我们看性能,因为这里的性能差异并不大。因为我们生成了几乎相同的代码,它稍微快一点。无符号类型。抱歉,是无符号的。稍微快一点。但不是快太多。这里的重点只是为了理解这两者之间有区别。
auto add_uint8(uint8_t a, uint8_t b) {
return a + b;
}
// what will the result be if we call add_uint8(255u, 1u)?
现在我们对有符号和无符号有了更多了解,我们可以谈谈同时使用两者的陷阱。这是互动部分。所以这里我请求你们尽可能和我互动。我们有一个非常简单的函数,它接受两个大小为 8 位的无符号整数。它返回一个 auto
类型的东西。它只是把它们加在一起。就这样。如果我们用 255(无符号)和 1(无符号)调用这个函数会发生什么?这个函数的结果会是什么?那个喊出来的人是对的。谁以为会是 0?
让我们看看为什么不是 0。所以让我们当一会儿编译器。如果我们看编译器,因为我们使用的类型是 uint8
,它们比 int
类型小,不管它是什么,它必须提升(promoted)为 int
,为有符号 int
。甚至不是无符号的。它只是被提升为有符号 int
。然后因为我们用了 auto
,编译器说,嘿,你的返回类型是 int
。你得到 256。你以为会是 0。你以为会溢出。但并没有。
uint_8t add_uint8(uint8_t a, uint8_t b) {
return a + b;
}
// what will the result be if we call add_uint8(255u, 1u)?
现在让我们看一个几乎相同的函数。但这里的返回类型是具体的。我们说我们想要得到 uint8
返回。当然这里如果我们做同样的事情,编译器会对类型进行窄化(narrowing)。所以它创建了一个 int
。它把它们相加了。但接着它把它窄化回 unsigned char
。然后,当然,我们会得到 0。
auto my_add(auto x, auto y) {
return x + y;
}
// what will be the result if we will call
// my_add(uint64_t(1), int64_t(-2)) ?
让我们看另一个函数。这个函数完全是 auto
的。所以你返回一个 auto
类型。你得到一个 auto
类型。X 和 Y。你只是把这两个加在一起。非常简单。所以现在我想用这些类型来加它们。我想用 1(无符号 int 64)和 -2(有符号 int 64)来做这个。这里的结果会是什么?这里的结果会是有问题的。因为我们给了它有符号和无符号类型,有符号类型将被提升为无符号类型。然后你只是把两个无符号类型加在一起。你会返回一个无符号类型。这意味着你会得到这个数字。不是你以为的那样。你也许以为会是 -1。但不是。
// MIXING INTEGER TYPES MAY CAUSE HORRIBLE BUGS
uint64_t count(uint64_t size){
uint64_t count;
for (int i = 0; size - i >= 0; i++){
count++;
}
return count;
}
另一个快速的例子是一个愚蠢的计数代码。它接收一个 u64
类型的大小 size
。它创建一个局部变量。然后它在一个 for 循环中从 0 开始运行。但我们原始的心智说,是的,是个 for 循环。我们放个 int
。但 size
是无符号的。所以这里发生的是 i
将被提升为无符号的。然后这个循环将永远运行。这是一个无限循环。
它会是 UBSAN(未定义行为消毒器)捕获的。是的。(有人小声说)哦,是的。抓得好。谢谢。好的。
// MIXING INTEGER TYPES MAY CAUSE HORRIBLE BUGS
void decode(std::byte* bytes, int size){
if (size == 0) return;
std::byte decoded[255];
for (uint64_t i = 0; i < size; i++){
decoded[i] = static_cast<std::byte>(static_cast<uint8_t>(bytes[i])^0xc);
}
}
可能发生的另一件事是,看看这个函数。我们足够聪明地说如果是零,我们什么也不做。如果不是零,我们创建一个 255 字节的缓冲区。然后我们在一个循环中运行。然后在 for 循环中运行。但再次,有人决定为 size
选择 int
而不是 unsigned int
。因为我们为 for 循环选择了无符号整数,因为我们以为这样更好。它永远不会是负数。它会被再次提升。你会有一个溢出。这里是缓冲区溢出。如果有人错误地,即使有人不小心提供了 -1,这也是溢出。只是为了澄清,所有这些小代码片段都来自实际的代码库。人们写的。我不是编造这些东西的。
void do_something(std::byte* bytes, uint32_t size){
for (auto i=0; i < size; i++){
}
}
这个例子和这种模式出现得越来越多。这让我每天都越来越生气。人们认为 auto
真的很酷。我认为 auto
很酷。但你不必到处都用它。如果你不完全理解给 auto
赋零值会做什么,你就是在创造一个怪物。那么 i
的类型会是什么?
整数。但 size
不是整数。所以我们又有所有这些提升到无符号整数的问题。这是个问题。人们确实这么做。使用字面量是件好事。因为字面量是常量。但你必须理解,我们在语言中有很多字面量。如果你想聪明地使用 auto
来做正确的事情,你必须理解如果你用 auto
赋值会发生什么。
auto a1 = 0;
auto a2 = 0u;
auto a3 = 0l;
auto a4 = 0ul;
auto a5 = 0ll;
auto a6 = 0ull;
int a1 = 0;
unsigned int a2 = 0U;
long a3 = 0L;
unsigned long a4 = 0UL;
long long a5 = 0LL;
unsigned long long a6 = 0ULL;
所以这里我有一个 a1
到 a6
的例子。每一个都会是不同的东西。编译器决定它是 int
, unsigned int
, long
, unsigned long
, long long
, unsigned long long
。你必须理解这些东西。你必须理解这些东西才能在那里实际使用 auto
。
谁知道 size_t
?很好。谁知道为什么创建了 size_t
?好的。所以 size_t
只是一个无符号整数。它用于大小操作。它定义在 <cstddef>
中。它有 max int size
。它在 C89 中引入。很久很久以前。它被引入是为了解决可移植性问题。可移植性问题基本上是,有时 unsigned int
不足以表示所有内存。而 unsigned long long
又太大,不能表示内存。所以你白白浪费了很多内存空间。所以它是为此实现的。并且它是可移植的。所以在每个系统中,它的大小不同。然后你就能正确地表示自己。
在 POSIX.1 2017 中,我们得到了一个叫 ssize_t
的东西。它和 size_t
几乎一样。它没有被标准化。它在 POSIX 中被标准化了。它的意思是做同样的事情。但至少它也能表示 -1。在很多情况下它是有益的。
for (int i = 0; i < container.ssize() - 1; ++i)
在我看来,像这个将在 C++23 中可用的代码片段,我不记得了。所以别抓住我的话不放。在 C++20 中,我们已经有一个函数 std::ssize
来做这个。但基本上我想在这里做的,我们现在还不能做,因为所有容器都有 size
。而 size
是无符号的。如果我从它减去 1,它会创造一个怪物。它几乎会为我们创造一个无限循环。用 ssize_t
,如果这个向量是空的,那么我们就不会循环,也不会运行。所以这很简洁。我认为这是一个很好的用法示例。在标准中也对此进行了很多讨论。
auto a7 = 0z;
auto a8 = 0uz;
long a7 = 0L;
unsigned long a8 = 0UL;
在 C++23 中,我们得到了更多的字面量。我们得到了 z
字面量和 uz
字面量,它们基本上是 ssize_t
和 size_t
字面量。在我的系统上,它是 long
和 unsigned long
。
uint64_t do_it(uint64_t count){
return 1 << (count % 64);
}
随堂测验各位。所以和我一起玩一下。这是一个非常简单的函数,它接收一个 64 位无符号整数。它对其取模 64。然后左移 1 位。这里会发生什么?举手?有人吗?是的?
你能到麦克风这边来吗,请?
1 是 int
。你通常是 32 位的,所以你可能会移位过度。谢谢。这正是答案。
所以再次,这里有人不理解 C++ 中的字面量是什么。他或她在这里使用了错误的字面量类型,基本上造成了未定义行为。所以当你使用这些类型时,你必须非常非常小心。现在,在我们理解了陷阱以及有符号和无符号整数是什么、它们在内存中如何表示之后,我们实际上可以去看一些比我们刚才看到的更复杂一点的东西。
uint64_t arc_unsigned(uint64_t n){
uint64_t sum = 0;
for (uint64_t i = 1; i <= n; i++){
sum += i;
}
return sum;
}
int64_t arc_signed(int64_t n){
int64_t sum = 0;
for (int64_t i = 1; i <= n; i++){
sum += i;
}
return sum;
}
所以我想给你们展示一个计算等差数列(arithmetic series)和的函数。等差数列基本上是一系列数字,其中每两个连续数字之间的差是常数。所以从 1 到 n,是一个等差数列。要计算等差数列的和,我们有一个非常简洁漂亮的公式,就是 (a1 + an) * n / 2,也就是首项加末项,乘以元素个数,除以二。非常简单。如果我们看 C++ 代码,我又有两个函数。一个函数只使用无符号类型。另一个函数只使用有符号类型。但它们做的是同样的事情。它们只是在一个循环中运行并加到总和里。没什么特别的。当然,数字不能是负数。这里所有的数字都是正数。所以逻辑上它们是相同的。
谁认为无符号的会更快?谁认为有符号的会更快?几乎相同数量的人。这很有趣。当然,让我们看看代码。
arc_unsigned(unsigned long):
test rdi, rdi
je .LBB7_1
mov ecx, 1
xor eax, eax
.LBB7_4:
add rax, rcx
add rcx, 1
cmp rcx, rdi
jbe .LBB7_4
ret
.LBB7_1:
xor eax, eax
ret
让我们看看汇编代码。无符号版本的汇编代码并不那么糟。它没那么糟。我们做的第一件事是测试(test
)。基本上,test
是为 je
设置一个标志,如果 RDI
是 0。如果 RDI
是 0,基本上我们接收到大小为 0,我们什么都不用做。然后我们跳到这里,我们使用一个非常非常花哨的置零方法,叫做 XOR
。我们只是把它异或置零并返回 RAX
。但如果不是 0,那意味着我们有工作要做。我们用 1 初始化 ECX
。然后我们置零 RAX
。然后我们跳进一个循环。我们把 RAX
和 RCX
相加。这基本上就是在加总和。我们增加索引。然后我们检查索引是否已经达到我们需要的大小。如果它小于或等于,它会跳回循环。如你所知,循环性能不高。在这种情况下,有很多分支相关的事情你不想做。但编译器还是在这里创建了一个循环。
arc_signed(long):
test rdi, rdi
jle .LBB8_1
lea rax, [rdi - 1]
lea rcx, [rdi - 2]
mul rcx
shld rdx, rax, 63
lea rax, [rdx + 2*rdi]
add rax, -1
ret
.LBB8_1:
xor eax, eax
ret
如果我们看有符号版本,我们可以看到这两个块和无符号版本是一样的。没有变化。但如果我们看这个块,它基本上是这个公式。我们不会深入代码,因为它稍微复杂一点。但编译器理解了它可以使用公式,不需要循环。很好。
如果我们看这里的性能,有符号版本比无符号版本快 40 倍。有人能猜到为什么吗?为什么?
麦克风。因为对于无符号整数,溢出是定义好的行为。所以它必须处理它。谢谢。所以,是的。它(有符号的情况)是未定义行为。我们只是利用了一个不好的东西来为我们自己服务。而未定义行为就是未定义行为。它只是说编译器可以做任何它想做的事。所以大多数时候,并非总是,编译器说,嘿,它不可能发生。所以如果它不可能发生,我就做我想做的。它就直接加上了。它可能会溢出。所以它可能运行一千次都没问题。但会在第一千零一次失败。如果你换了编译器,它的行为会不同。这是未定义行为。所以不要利用未定义行为。
在这里我们进入这个部分。那么我们该怎么做?我们想要程序的性能。但我们希望程序是定义良好的。在这个政府发通知说 C++ 很糟糕、不要用 C++ 的时代,我们不能为了我们的优势而利用未定义行为。所以我们必须始终尝试创建定义良好且正确使用的程序。这也许是整个演讲最重要的部分。就是如何让你的代码更安全一点。
-Wall -Wextra -pedantic -Werror
所以第一件事是,如果现在你在你的构建系统中没有使用这些标志,去找你们的 DevOps 团队或构建团队或不管谁,告诉他们,我们需要这些标志。如果你不使用这些标志,它会掩盖很多事情,编译器只会说,没那么糟。你不会看到有符号和无符号类型比较的警告。你不会看到溢出。你什么也看不到。但如果你用了它,首先会非常、非常痛苦,因为你必须修复你所有的问题。但等你修复了那些问题后,代码会更好、更安全。警告(Warnings),对我来说,就是错误(errors)。你的系统中不应该有警告。我看到很多…我看到很多、很多系统直接忽略警告。我去公司,他们给我展示,我们是这样编译的。然后我看到像 300 个警告。我说,你确定你的代码工作正常吗?他们有大量的未定义行为。而他们就是看不到。所以闭眼不看没有帮助。尤其是在一种如此不安全的语言中。所以让我们更安全。
<source>:10:11: error: comparison of integers of different signs: 'unsigned int' and 'int'
10 | if (x == -10){
| ~ ^ ~~~
1 error generated.
这只是我们会得到什么的一个例子。所以 x
是一个无符号整数。-10
是一个整数(int
)。你的编译器会告诉你,嘿,你做错了事。我会做一些可能伤害你的事。所以请修复它。如果你真的想那样做,你必须对它做点什么。自己做一个静态转换(static cast),或者用 std::launder
做重入路径,或者其他什么,但不要就这样放着。
如果可能,使用更新的编译器。更新的编译器几乎总是修复了它们的 bug,性能也更好。它几乎总是带来新的 bug,然后你利用的那些未定义行为,因为它们以前工作,现在停止工作了,你必须修复那些。所以我作为一个编译器开发者,经常和这些东西斗争。但通常,通常在你修复代码之后,性能会变得更好,好得多。在我们的例子中,我用 Clang 17 而不是 Clang 12 编译这个,我们为等差数列得到了相同的性能。
相同的性能。不是快 40 倍或慢 40 倍。它们现在一样了。
arc_unsigned(unsigned long):
test rdi, rdi
je .LBB1_1
inc rdi
cmp rdi, 3
mov ecx, 2
cmovae rcx, rdi
lea rax, [rcx - 2]
lea rdx, [rcx - 3]
mul rdx
shld rdx, rax, 63
lea rax, [rdx + 2*rcx]
add rax, -3
ret
.LBB1_1:
xor eax, eax
ret
arc_signed(long):
test rdi, rdi
jle .LBB0_1
lea rax, [rdi - 1]
lea rcx, [rdi - 2]
mul rcx
shld rdx, rax, 63
lea rax, [rdx + 2*rdi]
dec rax
ret
.LBB0_1:
xor eax, eax
ret
这是代码。所以你可以看到这里没有循环了。有符号版本的代码仍然是未定义行为,但有符号版本的代码只用了几个小指令,所以差异几乎察觉不到。编译器在变得更好。
-fsanitize=signed-integer-overflow
-fsanitize=unsigned-integer-overflow
我建议的另一件事是使用这些标志。谁以前见过这些标志?谁使用这些标志?一个人?哇。两个。三个。有一些使用。这些很棒。它们基本上是说,我不允许你再做溢出了。如果有溢出,我会为你捕获它。这些标志最神奇的地方是,在发布模式下几乎没有性能损失。所以你可以使用它们,它不会影响你。你的程序会更安全。
// Use special types for better performance
int_fastN_t
uint_fastN_t
标准另一个补充是 fastN
。fastN
整数只是一种告诉编译器的方式,我不在乎你用什么类型,用什么整数类型。我只知道我需要它是无符号或有符号的。你为我选择大小。选择性能最好的大小。编译器会为你做这件事。通常编译器比你做得更好。所以通常当我看到人们使用带大小的版本时,他们几乎总是用 u64
, u64
。它对一切都更好吗?也许不是。利用这些东西。
你必须理解你的 CPU。所以我们讨论了很多关于 x86 的内容。但还有不同的 CPU。我用 ARM、RISC-V 和我们自己的 CPU。这些 CPU 中的每一个都有不同的指令集架构。它们有不同的行为。它们承诺不同的东西。所以如果你在一个项目工作并且使用 C++,至少要了解你正在使用的 CPU。你必须了解该 CPU 供应商承诺你会得到什么,有什么风险或承诺。不要害怕看汇编代码。如你所见,它没那么糟。我们看了很多汇编,我给你看了 x86 的汇编,这是世界上最糟糕的汇编。最难读。RISC-V 和 ARM 要容易得多。所以不要害怕那样做。你会学到很多关于你程序的东西。
// Use special helpers from the standard
// - MAKE_SIGNED
// - MAKE_UNSIGNED
auto make_signed_ver(auto val){
return std::make_signed_t<decltype(val)>(val);
}
constexpr auto val_signed = make_signed_ver(uint64_t(10));
static_assert(std::same_as<const int64_t, decltype(val_signed)>);
static_assert(val_signed == int64_t(10));
另一个好事是在 C++11 中,你得到了新的辅助工具。有 make_signed
辅助工具和 make_unsigned
辅助工具。这些辅助工具所做的就是获取你请求的类型,并根据辅助工具返回有符号或无符号类型。它看起来像这样。如果我们有一个函数,它接收一个 auto
(可以是任何东西),并返回任何东西。在这个函数内部,我们只请求 make_signed_t<T>
。我们取 val
的 decay
类型(std::decay_t<decltype(val)>
),所以它是某种类型。假设它是无符号的。我们提供 val
本身。所以它会做的是,取这个类型,创建一个带符号的最佳匹配类型,并为你初始化它。如果我们看下面的例子,它基本上是说,我用 uint10_t
和 10 创建它。然后我检查我确实得到了一个类型是 const int64_t
,const
是因为我们用了字面量,所以它是一个 const
类型。然后我检查它仍然是 10,它确实是。所以如果你使用混合类型,这些辅助工具对你来说会非常有益。
// Use C++20 safe comparators
std::cmp_equal: ==
std::cmp_not_equal: !=
std::cmp_less: <
std::cmp_less_equal: <=
std::cmp_greater: >
std::cmp_greater_equal: >=
如果你能使用 C++20,这些函数(std::cmp_less
, std::cmp_equal
等)非常棒。基本上,这些是用于混合类型的安全比较函数。所以如果你想使用混合类型,并且你知道在你的系统中有时会使用混合类型,这些函数会为你完成工作。它们承诺会为你做正确的事情,不会做意外的事情,不像你自己写代码。
int64_t func(auto x, auto y){
if (x < y) return y;
return x;
}
为了向你展示它是如何工作的,我们有一个函数总是返回 int64_t
。它接收两个参数,x
和 y
。它有一个 if
检查 x
是否小于 y
,然后我们返回 y
。如果不是,我们返回 x
。基本上,这是一个非常简单的 max
函数。如果我们用 -10
(一个 int
)和 20UL
(一个 unsigned long
)调用这个函数,我们会得到什么?-10。因为 -10
会被提升。因为它被提升,它是一个比 20 大的数字。因为它更大,-10
胜出。并不是真正的最大值。
int64_t func(auto x, auto y){
if (std::cmp_less(x, y)) return y;
return x;
}
你要做的就是用这个函数(std::cmp_less
)。只需用 std::cmp_less
比较。如果你使用这个函数,我们用相同的参数调用相同的函数,你会得到 20。正是你想要的。我们在这里使用了混合类型。多酷。简单。可读。并且安全地完成了工作。最重要的部分是安全。
我无法再强调这一点。不要仅仅因为 auto
很酷就用它。我喜欢 auto
。我经常用它,因为我是一个泛型编程者。我用很多模板元编程。我使用它是因为我需要它。但无缘无故地使用它,至少是有问题的。那容易出错,你可能会因为一个 bug 而烧脑,不得不调试几天,就因为你用了 auto
来做一些你并不完全理解自己在做什么的事情。尽可能使用具体类型。我给你展示过一个函数,它不是泛型的,有人试图在那里放 auto
。为什么?你完全知道你正在使用的类型。为什么不放你需要的类型?为什么要耍酷?并非总是如此。你不必总是那样。安全是重要的部分。尽可能使用现代循环。基于索引的循环是…有人说它们已经是邪恶之源,还是只是我?就当是我说的吧。好吧。使用索引循环,你可能会溢出。你可能做错事。你可能会混合匹配类型。它们是有问题的。但如果你使用正确的 for each
或基于范围的循环或范围,你会得到你需要的。而且它不会是不安全的。
最后一件事是强类型。尽可能使用强类型。强类型不会造成我们见过的问题。如果你想用整数表示价格,例如,创建一个 Price
类型。不要只用整数,因为如果你用整数,你会到处得到隐式转换。
using strong_int = int;
这是我实际上见过的事情。不是用这个名字,但确实有人来找我说,嘿,我创建了一个强整数。不,你没有。你创建了一个整数的别名,你会遇到和整数一样的问题。所以,别那样做。别以为它是强类型。
struct strong_int {
explicit strong_int(int i) : i_{i} {}
private:
int i_;
};
如果你想创建一个强类型,像这样做。有很多不同的方法来创建强类型。你不必完全这样做。但我喜欢这个,因为它使用了显式构造函数,表明我知道我是什么。我不会为你做隐式转换。你必须知道你是谁,这简直太棒太安全了。所以这是正确的方式。
如果你要从这次演讲中学到什么,我希望你记住 C++ 可以是安全的。你只需要理解它。即使 C++ 中最简单的部分也可能真的、真的有害。所以,你必须理解你正在做的事情。你必须确定并且必须好奇。学习你的汇编,理解你在做什么,学习标准,你就会安全且高效。所以,我们到结尾了。非常感谢。如果你们有问题,请问。
抱歉,但我能回到我最初的问题吗?关于那个 a 加 b 除以二的事情?所以,我说它们不同的原因是,从逻辑上讲,它们不同是因为对于有符号整数,像,对于第一个函数,它并不简单地像数学中的 a 加 b 除以二,因为我们实际上更像是,如果 a 加 b 大于零,那么我们返回 a 加 b 除以二向下取整。不。然后,并且,再次,你需要看汇编代码。编译器做的正是你说的,这就是为什么你有不同的代码。不,不,不。我的意思是,像,有一个 else,否则我们做 a 加 b 向上取整。所以它,但对于无符号整数,我们总是做 a 加 b 除以二。如果,假设所有整数都是偶数… 不,再次,再次,不,不,抱歉,不。你总是有舍入模式。你总是要舍入。所以因为你用的是整数类型,你要舍入。它总是会向上取整或向下取整。这取决于系统,但它总是使用它。你可以看到,因为它用的是移位,逻辑的或算术的,它必须做点什么来让舍入模式正确。我的意思是,像,例如,如果我们断言所有整数都是正的,即使对于第一个实现,它也会生成相同的代码。它生成不同的代码是因为它必须处理一个你不需要为无符号整数处理的情况。它必须处理另一个情况的原因是数学上就不同。像,对于无符号整数,公式是 (a + b) / 2 向下取整,而对于有符号整数,它是 (a + b) / 2 向下取整,仅当 a + b 非负时。如果它是负的,那么公式就变了。现在是 (a + b) / 2 向上取整。但我不是在说不是你说的那样。我是想说正是你说的。你只需要理解如果你用有符号或无符号,你会得到不同的东西。这正是重点。所以人们不这么想。他们认为只是功能不同。他们不理解生成的代码会有点不同。你也看到了。你在数学上理解它,这很棒。但它总是,你必须时刻把它记在心里,理解你在做什么。这正是这次演讲的目的。理解你在做什么。不要仅仅因为你想放一个整数类型就放一个整数类型。不要到处放 int
。如果你知道它永远是无符号的,你知道它永远不会小于零,就不要用它。这正是重点。但我们也可以通过例如权威来实现这个。抱歉。稍后来找我。
你能展示带 max 函数的那张幻灯片吗?那个愚蠢的 max 函数。或者,是的,这个?嗯,是的,好的,那个比较。所以,你基本上是说写不同的参数让它安全或不安全。对我来说,这似乎像是在把不安全的东西推来推去,因为从根本上说,如果你用 ULONG_MAX
和 -1
调用它,就没有正确答案。你只是在决定你将如何失败。我完全同意那个说法。但我想说的是,基本上人们有时写的代码完全错误。而且,如果你不使用这个函数,你会以比使用这个函数更糟的方式崩溃和烧毁。至少这个函数会给你一个还算正确的答案。好的,很公平。是的,David。所以,我认为当你说 C++ 是安全的,只要你懂得如何使用它,这有点误导性。这有点像在说,嘿,开车不系安全带、撞穿窗户等等都是完全安全的,只要你开车时小心。我认为你很好地证明了 C++ 一点也不安全,因为谁他妈的会在真正开始写生产代码之前学习所有这些规则?就像你在说,好吧,任何人都可以做开胸手术,只要你知道怎么做,对吧?
这不是同一个论点。对于 C++ 中小于问题的答案是我们在这个晦涩的头文件里做了一个晦涩的库调用(std::cmp_less
),Elias 和我,我们都在标准化委员会,我们像,我们不知道这东西。如果那就是这个问题的答案,那么 C++ 并不安全,而且据我所知,它也没有变得更安全。David,所以我无法反驳那个。我不能。当然。我想说的是不要用 Rust。这就是我想说的。
那将是个错误。
嗨。所以,是的,我的问题是类似的。如果我们觉得我们有一个比比较运算符更好的函数,为什么我们没有为类型重载比较运算符?向后兼容性。首先,它只是在 C++20 才加入的,所以人们使用常规的运算符。你不能就这么移除那些。有些人利用了 C++ 中的未定义行为。显然,我听说过。如果你把它拿走了,他们会杀了你,因为他们会说,嘿,我的程序跑得那么快,现在你把它搞死了。即使在逻辑上,它也会做不同的事情。所以如果他们写了不好的东西并且依赖那个不好的东西,那是他们自己的错。他们会永远保留那个不好的东西,因为那是遗留代码。我们不会碰遗留代码。如果 Peter 在这里,也许他会碰遗留代码,但那是世界上唯一的人。好的。谢谢。
还有其他人吗?哦,是的。哦,抱歉。抱歉。抱歉。请说。很棒的演讲。我只有一个问题。所以当你在,有一张幻灯片里你说无符号 int 比 int 慢 40 倍。我不太明白为什么。未定义行为。因为有符号 int 没有定义溢出,编译器就直接说它不可能发生。编译器经常这么做。因为它不可能发生,我可以做任何我想做的事。我可以用这个简洁的公式,一切都会很好。所以它更快了。大多数时候它会工作。有时不会。好的。谢谢。不客气。是的。一个非常实际的问题。假设我存储了一堆数字,我用它们来索引一个向量。那么它们应该是什么?int
, uint
, size_t
。指南说不要用无符号的,用有符号的。不要在上面做任何算术。
这真的取决于。这是个很难回答的问题。正如你在演讲中看到的,我用这个也用那个。所以对我来说,它是适合工作的正确武器。所以如果你因为某种原因需要某物是无符号的,抱歉,有符号的,用有符号的。我展示了一个例子,使用一个非常简单的函数配合 ssize_t
,你可以得到非常简洁的东西并清理你的代码。所以如果你需要那样的东西,就那样做。很难让我确切地说用什么。这真的取决于。这是一个非常简单的用例,只是索引。索引什么?索引一个向量?是的。只是索引一个向量,我会选择无符号的。谢谢。不客气。但这是我。
是的。不过索引的问题是,你可能会对索引做减法,你可能会索引一个指针,而你可以用负数索引一个指针。对。对。对。你说得对。所以是的,整数。因为指针差。他说得对。谢谢。
如果我们没有更多问题了,我们时间到了。所以非常感谢大家能来。