C ++中的变量如何存储它们的类型?


42

如果我定义某种类型的变量(据我所知,只是为变量的内容分配数据),它如何跟踪它是哪种类型的变量?


8
您在“ 如何跟踪 ”中的“ ” 指的是谁/什么?编译器或CPU或其他类似语言或程序?
Erik Eidt


8
@ErikEidt IMO OP显然是用“它”来表示“变量本身”。当然,这个问题的两个字答案是“不是”。
alephzero

2
好问题!考虑到所有确实能存储其类型的精美语言,今天特别相关。
Trevor Boyd Smith,

@alephzero这显然是一个主要问题。
a安

Answers:


105

变量(或更笼统地说:C的“对象”)在运行时不存储其类型。就机器代码而言,只有未类型化的内存。相反,对此数据的操作会将数据解释为特定类型(例如,浮点数或指针)。这些类型仅由编译器使用。

例如,我们可能有一个struct或class struct Foo { int x; float y; };和一个variable Foo f {}。如何auto result = f.y;编译字段访问?编译器知道这f是类型的对象,Foo并且知道Foo-objects 的布局。根据特定于平台的详细信息,它可能被编译为“将指针指向的开头f,添加4个字节,然后加载4个字节,然后将此数据解释为浮点数。”在许多机器代码指令集中(包括x86-64) )有不同的处理器指令可用于加载浮点数或整数。

C ++类型系统无法为我们跟踪类型的一个示例是类似的联合union Bar { int as_int; float as_float; }。联合最多可包含一个不同类型的对象。如果我们将对象存储在联合中,则这是联合的活动类型。我们只能尝试使该类型退出联合,否则任何其他情况都是未定义的行为。要么在编程活动类型时“知道”,要么创建标记的联合,在其中单独存储类型标签(通常是枚举)。这是C语言中的常用技术,但是由于我们必须使并集和类型标记保持同步,因此这很容易出错。一个void*指针是类似于工会,但只能容纳指针的对象,除了函数指针。
C ++提供了两种更好的机制来处理未知类型的对象:我们可以使用面向对象的技术来执行类型擦除(仅通过虚拟方法与对象进行交互,因此我们不需要知道实际的类型),或者可以use std::variant,一种类型安全的联合。

在一种情况下,C ++确实存储了对象的类型:如果对象的类具有任何虚方法(“多态类型”,也称为接口)。虚拟方法调用的目标在编译时未知,并在运行时根据对象的动态类型(“动态调度”)进行解析。大多数编译器通过在对象的开头存储一个虚拟函数表(“ vtable”)来实现此目的。vtable也可用于在运行时获取对象的类型。然后,我们可以在编译时已知的表达式静态类型和运行时对象的动态类型之间进行区分。

C ++允许我们使用typeid()为我们提供std::type_info对象的运算符来检查对象的动态类型。要么编译器在编译时就知道对象的类型,要么编译器已经在对象内部存储了必要的类型信息,并且可以在运行时检索它。


3
非常全面。
Deduplicator

9
请注意,要访问多态对象的类型,编译器仍必须知道该对象属于特定的继承族(即,具有该对象的类型化引用/指针,而不是void*)。
Ruslan

5
+0,因为第一句不正确,最后两段对其进行了更正。
Marcin

3
通常,存储在多态对象开始处的是指向虚拟方法表的指针,而不是表本身。
彼得·格林

3
@ v.oddou在我的段落中,我忽略了一些细节。typeid(e)内省表达式的静态类型e。如果静态类型是多态类型,则将评估表达式并检索该对象的动态类型。您不能将typeid指向未知类型的内存并获得有用的信息。例如,联合的typeid描述联合,而不是联合中的对象。a的typeid void*只是一个空指针。并且不可能取消引用a void*以获得其内容。在C ++中,除非以这种方式进行显式编程,否则不会进行装箱。
阿蒙(Amon)

51

另一个答案很好地说明了技术方面,但是我想添加一些常规的“如何考虑机器代码”。

编译后的机器代码相当愚蠢,它实际上只是假设一切正常。假设您有一个简单的功能,例如

bool isEven(int i) { return i % 2 == 0; }

它需要一个int,然后吐出一个布尔值。

编译后,您可以将其视为类似于此自动橙色榨汁机的东西:

自动橙汁机

它吸收橘子,然后返回果汁。它可以识别进入的对象类型吗?不,他们应该只是橘子。如果它得到一个苹果而不是一个橘子怎么办?也许会破裂。没关系,因为负责任的所有者不会尝试以这种方式使用它。

上面的函数是相似的:它被设计为采用整数,并且在喂入其他东西时可能会中断或做不相关的事情。(通常)没有关系,因为编译器(通常)会检查它永远不会发生-而且在格式正确的代码中它也永远不会发生。如果编译器检测到函数将获得错误的类型值的可能性,则它拒绝编译代码,而是返回类型错误。

需要注意的是,在某些情况下,编译器将通过格式错误的代码。例如:

  • 不正确的类型转换:显式转换被认为是正确的,它是程序员,以确保他没有铸造void*orange*时,有上的指针的另一端一个苹果,
  • 内存管理问题,例如空指针,悬空指针或范围后使用;编译器找不到其中的大多数,
  • 我确定我还有其他东西想念。

如前所述,编译后的代码就像榨汁机一样-它不知道它要处理什么,它只是执行指令。如果指示错误,则会中断。这就是为什么C ++中的上述问题会导致无法控制的崩溃。


4
编译器尝试检查该函数是否传递了正确类型的对象,但是C和C ++太复杂,以致于编译器无法在每种情况下对其进行证明。因此,将苹果和橙子与榨汁机进行比较很有启发性。
Calchas

@Calchas感谢您的评论!这句话确实太过简单了。我对可能出现的问题进行了详细说明,它们实际上与问题有关。
Frax

5
哇,机器代码妙不可言!图片也使您的隐喻变得更好10倍!
Trevor Boyd Smith,

2
“我确定我还有其他东西想念。” - 当然!C'S void*胁迫到foo*,通常的算术促销,union类型双关语,NULLnullptr,哪怕只是一个坏的指针是UB等,但我不认为上市的所有这些事情出来会实质改善你的答案,所以它可能是最好的休假照原样。
凯文(Kevin)

@Kevin我认为没有必要在此处添加C,因为该问题仅被标记为C ++。并且在C ++ void*中不会隐式转换为foo*,并且union不支持punning类型(具有UB)。
Ruslan

3

变量具有像C这样的语言的许多基本属性:

  1. 一个名字
  2. 一种
  3. 范围
  4. 一生一世
  5. 一个位置
  6. 一个值

在您的源代码中,位置(5)是概念性的,并且该位置由其名称(1)引用。因此,变量声明用于为值(6)创建位置和空间,在源代码的其他行中,我们通过在某些表达式中命名变量来引用该位置及其所拥有的值。

仅在某种程度上简化一下,一旦程序被编译器翻译为机器代码,该位置(5)就是一些内存或CPU寄存器的位置,任何引用该变量的源代码表达式都将翻译为引用该内存的机器代码序列或CPU寄存器位置。

因此,当翻译完成并且程序在处理器上运行时,变量的名称实际上在机器代码中被忘记了,并且编译器生成的指令仅引用变量的分配位置(而不是变量的位置)。名称)。如果您要调试和请求调试,则与名称关联的变量的位置会添加到程序的元数据中,尽管处理器仍会使用位置(而不是元数据)看到机器代码指令。(这过于简化了,因为某些名称存在于程序的元数据中,用于链接,加载和动态查找的目的—处理器仍然只是执行程序所告知的机器代码指令,并且在该机器代码中,名称具有已转换为位置。)

对于类型,范围和生存期也是如此。编译器生成的机器代码指令知道位置的机器版本,该机器版本存储值。其他属性(例如type)作为访问变量位置的特定指令编译到翻译后的源代码中。例如,如果所讨论的变量是有符号的8位字节与无符号的8位字节,则源代码中引用该变量的表达式将转换为有符号字节负载与无符号字节负载,根据需要满足(C)语言的规则。因此,变量的类型被编码为将源代码转换为机器指令,机器指令每次使用变量的位置时,都会命令CPU如何解释内存或CPU寄存器的位置。

实质是我们必须通过处理器的机器代码指令集中的指令(以及更多指令)来告诉CPU该做什么。处理器对它刚刚执行或被告知的操作记忆很少-仅执行给定的指令,而编译器或汇编语言程序员的工作是为其提供完整的指令序列集以正确地操纵变量。

处理器直接支持一些基本数据类型,例如字节/字/整数/长符号/无符号,浮点数,双精度等。如果您将相同的内存位置交替地视为有符号或无符号,则处理器通常不会抱怨或反对。例如,即使那通常是程序中的逻辑错误。编程的工作是在每次与变量的交互时指示处理器。

除了这些基本的基本类型之外,我们还必须在数据结构中编码事物,并使用算法根据这些基本类型来操纵它们。

在C ++中,涉及多态性的类层次结构中的对象通常在对象的开头有一个指针,该指针指向特定于类的数据结构,这有助于虚拟分派,强制转换等。

总而言之,处理器否则不知道或不记得存储位置的预期用途-它执行程序的机器代码指令,告诉它如何操作CPU寄存器和主存储器中的存储。因此,编程是软件(和程序员)的工作,以有意义地使用存储,并向处理器提供一组一致的机器代码指令,以忠实地执行程序。


1
注意“翻译完成时,名称会被遗忘” ...通过名称(“未定义符号xy”)进行链接,并且很可能在运行时通过动态链接进行。参见blog.fesnel.com/blog/2009/08/19/…。没有调试符号,甚至没有剥离符号:动态链接需要函数名(我假设是全局变量)。因此,只能忘记内部对象的名称。顺便说一句,很好的变量属性列表。
彼得-恢复莫妮卡

@ PeterA.Schneider,从总体上看,您是完全正确的,链接器和加载器也参与并使用(全局)函数的名称和源于源代码的变量。
Erik Eidt

另一个复杂的问题是,根据标准,某些编译器会解释规则,这些规则旨在让编译器假定某些事情不会混叠,因为即使它们不涉及书面的混叠允许他们将涉及不同类型的操作视为未排序。给定类似的东西useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);,clang和gcc倾向于假设指向的指针unionArray[j].member2不能访问,unionArray[i].member1即使它们都是从相同的派生出来的unionArray[]
超级猫

不管编译器是否正确解释语言规范,其工作都是生成执行程序的机器代码指令序列。这意味着对于源代码中的每个变量访问(模优化和许多其他因素),它必须生成一些机器代码指令,这些指令告诉处理器用于存储位置的大小和数据解释。处理器不会记住有关变量的任何信息,因此每次应该访问变量时,都必须准确地指示如何执行该变量。
Erik Eidt

2

如果我定义某种类型的变量,它如何跟踪变量的类型。

这里有两个相关阶段:

  • 编译时间

C编译器将C代码编译为机器语言。编译器具有可以从您的源文件(和库,以及执行其工作所需的其他任何东西)中可以获得的所有信息。C编译器跟踪什么意味着什么。C编译器知道,如果您将变量声明为char,则为char。

它通过使用所谓的“符号表”来做到这一点,其中列出了变量的名称,变量的类型以及其他信息。它是一个相当复杂的数据结构,但是您可以将其视为仅跟踪人类可读名称的含义。在编译器的二进制输出中,不再出现这样的变量名(如果我们忽略程序员可能会请求的可选调试信息)。

  • 运行

编译器的输出-编译后的可执行文件-是​​机器语言,由操作系统加载到RAM中,并由CPU直接执行。在机器语言中,根本没有“类型”的概念-它只有在RAM中某个位置上运行的命令。该命令确实有他们与经营固定式(即,有可能是机器语言指令“添加存储在RAM位置为0x100和0x521这两个16位整数”),但没有信息任何地方在系统中的这些位置上的字节实际上代表整数。这里根本没有针对类型错误的保护措施。


如果您碰巧用“面向字节代码的语言”指代C#或Java,则绝不会从中省略指针。恰恰相反:指针在C#和Java中更为常见(因此,Java中最常见的错误之一是“ NullPointerException”)。它们被称为“引用”只是一个术语问题。
彼得-恢复莫妮卡

@ PeterA.Schneider,当然,存在NullPOINTERException,但是在我提到的语言(例如Java,ruby,可能是C#,甚至在某种程度上甚至是Perl)中,引用和指针之间有非常明确的区别-引用一起使用具有其类型系统,垃圾收集,自动内存管理等;通常甚至不可能显式声明一个内存位置(如char *ptr = 0x123C语言)。我相信在这种情况下,我对“指针”一词的用法应该很清楚。如果没有,请给我一个提示,然后在答案中加上一句话。
AnoE

指针在C ++中也“与类型系统一起” ;-)。(实际上,Java的经典泛型的类型不如C ++的强。)垃圾回收是C ++决定不强制执行的功能,但是实现可以提供一个功能,并且与我们用于指针的单词无关。
彼得-恢复莫妮卡

好吧,@ PeterA.Schneider,我真的不认为我们在这里变得水平。我删除了我提到指针的那一段,无论如何答案都没有做任何事情。
AnoE

1

在几个重要的特殊情况下,C ++确实在运行时存储类型。

经典的解决方案是有区别的联合:一个数据结构,其中包含几种类型的对象中的一种,再加上一个字段,它说明当前包含的类型。模板化版本在C ++标准库中为std::variant。通常,标记是enum,但是如果您不需要数据的所有存储位,则可能是位字段。

另一个常见的情况是动态类型。当您class拥有一个virtual函数时,该程序会将指向该函数的指针存储在虚拟函数表中,该将针对class构建时的每个实例进行初始化。通常,对于所有类实例,这将意味着一个虚函数表,并且每个实例都拥有一个指向相应表的指针。(这节省了时间和内存,因为该表将比单个指针大得多。)当您virtual通过指针或引用调用该函数时,程序将在虚拟表中查找该函数指针。(如果它在编译时知道确切的类型,则可以跳过此步骤。)这允许代码调用派生类型的实现,而不是基类的实现。

在这里使之相关的是:每个都ofstream包含一个指向ofstream虚拟表的指针,每个都ifstream指向ifstream虚拟表,等等。对于类层次结构,虚拟表指针可以用作标记,告诉程序类对象具有什么类型!

尽管语言标准没有告诉设计编译器的人员他们必须如何在后台实现运行时,但这是您可以期望dynamic_casttypeof起作用的方式。


“语言标准不会告诉编码人员”,您可能应该强调,有问题的“编码人员”是编写 gcc,clang,msvc等的人,而不是使用它们来编译C ++的人。
卡莱斯(Caleth),

@Caleth好建议!
戴维斯洛
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.