【C++进阶系列】:类型转换和IO流
🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz
💪 今日博客励志语录:
重要的不是选择本身,而是选择之后,你如何让它成为对的。
那么提到类型转换,想必各位读者并不陌生,那么在c语言期间,便有了类型转换这个机制,那么类型转换分为两种,分别是隐式类型转换以及显示类型转换,这里我们首先来回顾一下隐式类型转化
隐式类型转换
隐式类型转换是指在进行特定操作(如赋值、函数传参或计算算术表达式结果)时,当源对象的数据类型与目标对象的数据类型不匹配但两者存在关联关系时,无需程序员显式干预,编译器会自动完成类型转换以确保操作正确性的机制。
从上述定义可知,隐式类型转换的前提是源类型与目标类型之间存在关联。这种关联关系可以存在于:
- 内置类型之间
- 自定义类型之间
- 内置类型与自定义类型之间
以触发隐式类型转换最为常见的赋值操作为例。当赋值运算符的左操作数与右操作数的数据类型不一致时,编译器会尝试进行类型转换。赋值操作中涉及的操作数通常为数值类型。数值类型包括整型家族(如 char
, short
, int
, long
, long long
)以及浮点型(float
, double
)。数值类型是最常见的具有关联关系的数据类型。
若赋值运算符涉及数值类型,转换遵循特定规则:位宽较小或精度较低的类型向位宽较大或精度较高的类型转换。这里的“位宽”指数据类型在内存中占用的比特位数(bit width)。对于整型,其位宽从低到高依次为:char
-> short
-> int
-> long
-> long long
。因此,当左操作数和右操作数均为整型但类型不同时,右操作数的类型将按此规则转换。例如,若右操作数为 char
类型而左操作数为 long
类型,由于 long
的位宽大于 char
,右操作数将被转换为 long
类型。此过程在专业术语中称为整型提升 (Integer Promotion)。
#include <iostream>
#include <typeinfo> int main()
{long a = 100;int b = 10;auto c = a = b; // 赋值表达式 a = b 的结果类型是 long&,赋值给 cstd::cout << typeid(c).name() << std::endl; // 输出 c 的类型名称return 0;
}
对于浮点型,转换规则是精度较低的类型向精度较高的类型转换:float
-> double
。例如,若左操作数为 double
类型而右操作数为 float
类型,右操作数将被转换为 double
类型。
#include <iostream>
#include <typeinfo>int main()
{float a = 100.0;double b ;auto c = b = a; // 赋值表达式 b = a 的结果类型是 double& (因为 b 是 double,a 被提升后赋值)std::cout << typeid(c).name() << std::endl; // 输出 c 的类型名称return 0;
}
赋值操作也允许从位宽较大或精度较高的类型向位宽较小或精度较低的类型转换(即向下转换)。然而,相较于前述的向上转换(widening conversion),向下转换(narrowing conversion)是不安全的,因为它可能导致数据截断(data truncation)或精度损失。
另一种情况是当左操作数与右操作数分别为浮点型和整型时,转换方向取决于左操作数的类型(目标类型)。
综上,对于涉及数值类型的赋值运算符,只要左操作数与右操作数类型不同,编译器总会执行隐式类型转换,将右操作数的值转换为左操作数的类型。
上述讨论集中于内置类型之间。赋值运算符同样适用于内置类型与自定义类型之间,通常表现为内置类型作为右操作数,自定义类型作为左操作数。此时转换的前提是:自定义类型对应的类定义了接受该内置类型作为参数的构造函数(转换构造函数)。编译器会尝试调用此构造函数创建一个临时对象,然后(理想情况下)通过拷贝构造函数或移动构造函数初始化左操作数(目标对象)。现代编译器通常会优化此过程,直接调用转换构造函数在目标对象上进行初始化。因此,内置类型向自定义类型转换的关键在于目标类是否定义了合适的转换构造函数。
对于两个自定义类型之间的赋值操作(左操作数与右操作数类型不同),转换机制依赖于:左操作数类型(目标类)是否定义了接受右操作数类型作为参数的赋值运算符重载函数或拷贝/移动构造函数。编译器会根据左操作数当前的状态(是首次创建还是已存在对象)选择调用构造函数(初始化)或赋值运算符(赋值)。
理解了赋值运算符的隐式类型转换后,函数传参同样会触发隐式类型转换。若函数采用传值调用 (pass by value),实参传递给形参的过程本质上等同于一次赋值操作:形参相当于左操作数(目标),实参相当于右操作数(源)。其转换原理与上文所述赋值操作完全一致。
需要特别指出的是数组传参的情况。在 C/C++ 中,当尝试将数组传递给函数时,数组名会隐式转换(退化)为指向其首元素的指针类型。这也是一种隐式类型转换,其设计初衷是避免拷贝整个数组带来的性能开销,属于一种优化机制。
#include <iostream>void fun(int* ptr) // 形参声明为指针
{std::cout << ptr[1] << std::endl; // 通过指针访问数组元素
}
int main()
{int arr[10] = {0, 2}; // 初始化数组fun(arr); // 传递数组名,发生隐式转换(数组退化为 int*)return 0;
}
最后讨论算术表达式中的隐式类型转换。在计算纯数值类型的算术表达式结果时,若涉及的操作数类型不一致,编译器会尝试统一各操作数的数据类型。
情况一:所有操作数均为整型(无浮点型)
转换规则如下:
-
整型提升 (Integer Promotion):首先,所有小于
int
的整型(如char
,short
)会被提升为int
(如果int
能完整表示其值范围)。如果int
无法完整表示其值范围(例如unsigned char
或unsigned short
的最大值超出了int
的最大可表示正值),则会被提升为unsigned int
。这是由于算术表达式的计算由 CPU 的算术逻辑单元 (ALU) 执行,操作数和中间结果通常存储在寄存器中。在 32 位平台上,寄存器通常为 32 位(4 字节)。CPU 与内存的数据总线宽度也常为 32 位,意味着一次内存读取操作通常获取 4 字节数据。当读取一个char
(1 字节)或short
(2 字节)时,会读取其所在的整个 4 字节内存单元。为避免高位包含未初始化的随机值影响计算结果,编译器执行整型提升,用零(对于无符号类型)或符号位(对于有符号类型)填充高位,使其达到int
的位宽。 -
统一类型 (Usual Arithmetic Conversions):整型提升后,若操作数类型仍未统一,编译器会识别出表达式中等级 (rank) 最高的整型类型,并将所有其他操作数转换为该类型
整型等级规则:C++ 标准定义了整型类型的等级。通常,位宽更大的类型等级更高。等级从低到高大致为:
char
->short
->int
->long
->long long
。注意:同一类型的有符号 (signed
) 和无符号 (unsigned
) 版本具有相同的等级。 -
统一符号性 (Sign Handling):类型统一后,若等级相同的类型符号性不同(即有符号与无符号混合),无符号类型的优先级高于有符号类型。编译器会将所有操作数最终统一为表达式中出现的最高等级的无符号类型。在类型提升过程中,操作数原有的符号性保持不变。
如果等级最高的类型为有符号类型
T
,但存在一个类型U
,且U
的等级低于T
但T
无法表示U
的所有值时(例如在 32 位系统上,long
和unsigned int
同为 32 位,但long
的最大值小于unsigned int
的最大值),则最终所有操作数会被转换为T
对应的无符号版本(unsigned T
)而如果最高的类型为有符号的
T
,存在一个类型U
且U
的等级低于T
但T
能够表示U
的所有值(例如在 64 位系统上, long (有符号 64 位) 能表示 unsigned int (无符号 32 位) 的所有值 0 到 4294967295 ),则所有操作数最终会被转换为有符号类型T
。而如果最高的类型为无符号类型
T
,那么该无符号的类型T
是一定能够表示出等级低于T
的类型U
的所有值,所以这里所有操作数最终会被转换为T
的无符号版本(unsigned T
)
示例:算术表达式包含操作数类型 unsigned int
, long
, char
。
整型转换等级示例: 表达式包含 unsigned int a
, long b
, char c
。
- 最高等级类型:
long
(有符号) - 存在无符号类型
unsigned int
,等级 <long
long
不能表示unsigned int
的所有值- 根据规则 → 所有操作数转为
unsigned long
#include <iostream>int main()
{unsigned int a = 10;long b = 20;char c = 'a'; // ASCII 值为 97// 使用 decltype 获取表达式 a + b + c 的类型
decltype(a + b + c) result = a + b + c; // 表达式类型为 long
std::cout << typeid(result).name() << std::endl; // 输出类型名称
std::cout << result << std::endl; // 输出计算结果 (10 + 20 + 97 = 127)
return 0;}
情况二:算术表达式中包含浮点型
若表达式中存在浮点型操作数,即使其他操作数为整型,所有操作数最终会被统一转换为表达式中精度最高的浮点类型。转换方向为:float
-> double
-> long double
。整型操作数将先转换为对应的浮点类型(例如 int
转换为 float
或 double
),然后再参与运算。
#include <iostream>int main()
{unsigned int a = 10;long b = 20;double c = 20.9; // 使用 decltype 获取表达式 a + b + c 的类型
decltype(a + b + c) result = a + b + c; // 表达式类型为 double
std::cout << typeid(result).name() << std::endl; // 输出类型名称
std::cout << result << std::endl; // 输出计算结果 (10 + 20 + 97 = 127)
return 0;}
理解编译器隐式类型转换的规则和原理至关重要。编译器执行这些转换的本意是好的,旨在保证操作的语法正确性和结果的合理性。然而,在某些场景下,隐式转换可能导致意料之外的结果,引发逻辑错误。
例如,在算术表达式中,若操作数混合了有符号和无符号整型,且涉及负值:
- 有符号类型向无符号类型转换时,负值会变成一个很大的正数(基于模运算),可能导致计算结果溢出或与预期不符。
- 在比较运算符(如
<
,>
,==
)的场景下尤为危险。若比较的两个操作数类型不同(尤其是一个为有符号整型且可能为负,另一个为无符号整型),编译器会先执行类型统一。如果无符号类型的优先级更高(或等级相同),有符号操作数会被转换为无符号类型。此时,负数的有符号值会变成很大的无符号值,导致比较结果完全错误(例如-1 > 1U
在转换后结果为true
)。
综上所述,虽然隐式类型转换提供了便利,程序员必须警惕其潜在的陷阱,尤其是在涉及混合符号类型和向下转换的场景中。显式类型转换(casting)有时是必要的,以确保代码行为符合预期。
显示类型转化
显示类型转换是指程序员显式干预编译器,强制对某个类型进行转换。关键在于理解类型转换的本质:不同类型的变量或对象,其本质是内存中存储的一个二进制序列,而类型为编译器(和CPU)提供了解释该二进制序列的方式。因此,将变量或对象的数据类型进行转换,不会改变该变量或对象在内存中的实际位模式或布局。
当进行强制类型转换,将一个变量的数据类型转换为另一个不兼容的类型时,该变量的二进制序列会按照转换后的类型进行解释,这可能导致未定义行为
(Undefined Behavior, UB)。
例如,将整数类型或浮点类型转换为指针类型。指针变量存储的是其指向对象空间的首地址,该地址本质上是一个32位或64位的二进制序列。而数值类型如
int
或float
,在32位平台下通常也是4字节大小,本质上同样是一个32位的二进制序列。因此,将int
或
float
强制转换为指针时,其存储的内容本身不会改变。然而,编译器(和CPU)会将其当作一个内存地址处理,该地址对应的空间可能包含未定义或无效的数据。
当然,上述例子程序员通常不会使用。虽然强制类型转换允许在不相关联的类型间转换,但实践中通常用于相关类型之间。如前所述,算术表达式的计算以及比较运算符存在隐式类型转换机制。如果算术表达式中等级最高的类型 (usual arithmetic conversions) 是无符号类型,则所有操作数的类型都会统一为该最高等级类型的无符号版本。对于比较运算符,如果操作数类型涉及无符号类型,则另一个操作数也会被转换为无符号类型。
使用强制类型转换,意味着我们可以采取措施,将有符号类型显式地保持其符号性。
然而,强制类型转换也存在风险。最常见的问题之一是利用强制类型转换移除const
限定符。通过const
修饰符修饰局部变量,该变量具有const
属性,只能读取不能修改。需要注意的是:如果const
修饰的是全局变量或静态变量(位于全局/静态存储区),它们会被存储到只读数据段。只读数据段在内存页表中的权限被标记为只读 (Read-Only),任何写入尝试会触发硬件异常(如段错误 - Segmentation Fault),导致程序终止。
但是,函数中定义的const
局部变量通常位于栈区,栈区是可读写的。虽然尝试在代码中直接修改const
局部变量时,编译器会在编译时检查并报错,但我们可以使用一个const
指针指向该变量,然后将该const
指针强制类型转换为非const
指针。此时,这个非const
指针持有指向该const
局部变量的地址。由于指针是非const
属性,编译器会认为该指针指向的是非const
对象,因此可以通过这个非const
指针去修改const
变的值。注意,由于const
变量位于栈区(可读写),在物理内存层面修改其内容是可行的。
这违背了const
变量内容不变的期望,可能导致程序逻辑错误。然而,我们修改了const
变量后,程序可能会立即表现出问题。这是因为编译器可能进行优化:编译器认为const
变量的内容不会改变,且其尺寸较小,因此编译器可能会在const
变量定义时为它在内存中分配空间,但在后续代码中访问该const
变量时,编译器可能将其值直接嵌入到生成的机器指令中作为立即数 (immediate value),从而避免实际的内存访问(因为访问内存通常比直接使用指令中的立即数(立即寻址)慢)。
#include <iostream>int main()
{const int a = 4;const int* const_ptr = &a;int* ptr = (int*)const_ptr;(*ptr)++;for (int i = 0; i < a; i++){std::cout << a << std::endl;}return 0;
}
根据运行结果,可能产生错觉,认为const
变量的内容并未改变。但通过调试器查看监视窗口,可以看到const
变量的值确实被修改了。
因此,C/C++提供了volatile
关键字来抑制此类优化:
#include <iostream>int main()
{volatile const int a = 4;const volatile int* const_ptr = &a;int* ptr = (int*)const_ptr;(*ptr)++;for (int i = 0; i < a; i++){std::cout << a << std::endl;}return 0;
}
由此可见,强制类型转换可以绕过const
属性修改常变量。在继承方面,如果派生类以private
方式继承基类,派生类对象在内存中包含其基类子对象(内存布局为先父后子)。虽然派生类对象为基类成员分配了空间,但由于继承的访问控制(private
继承),无法通过派生类对象或指针直接访问基类成员。然而,利用强制类型转换,基于派生类对象的内存布局(成员变量(包括基类子对象)按照声明顺序排列,并遵循内存对齐规则),可以计算基类部分成员相对于派生类对象起始地址的偏移量。获取派生类对象的起始地址后,将其强制转换为char *
类型(因为char *
指针算术运算以字节为单位),即可访问基类的成员变量。注意,此操作属于未定义行为。
综上所述,C语言的类型转换,无论是强制类型转换还是隐式类型转换,都存在安全风险或可能导致未定义行为。因此,C++引入了更安全、更明确的类型转换运算符来改善这种情况。
C++类型转换
在C++中,相较于隐式类型转换,语言提供了更安全的强制类型转换机制。隐式类型转换(例如算术表达式和比较运算符中的自动转换)虽然意图是好的,但当操作数类型不同时,编译器统一类型的做法有时会导致逻辑错误。因此,C++将类型转换的控制权更多地交给了程序员。
为此,C++提供了四种特定的类型转换运算符:
static_cast
static_cast
用于C++认为最安全的类型转换场景,即在相关联的数据类型之间进行转换。
语法:
float b = 20.8;
double a = static_cast<double>(b); // 将 float 转换为 double// 通用形式:
static_cast<目标类型>(表达式);
理解其语法后,很自然会问:static_cast
与传统的 C 风格强制类型转换(目标类型)表达式
有何区别?
C++ 引入新机制必然有其优势。传统的强制类型转换允许转换相关联类型,但也允许转换非相关联类型(例如指针与整型之间的转换)。
static_cast
的关键改进在于其内置了类型安全检查。编译器会检查源表达式类型与目标类型是否相关联(如数值类型间的转换、存在转换构造函数的类等),并阻止不相关类型之间的转换(例如 int
到 int *
):
#include <iostream>
int main() {int a = 10;int* ptr = static_cast<int*>(a); // 错误:无效的 static_cast(类型不相关)return 0;
}
因此,static_cast
通过阻止不相关类型的转换,提供了比传统强制类型转换更高的安全性。
实现机制:
static_cast
并非类模板,其本质是一个编译时指令标记。编译器遇到static_cast
时,会执行类型相关性检查。若类型相关,则生成与等效 C 风格强制转换相同的底层指令
这里我可以写一个static_cast类型转换的代码以及采取强制类型转换的代码,两者都是转换相同类型的源对象以及相同的目标类型,通过这个代码就能验证,static_cast底层生成的指令和强制类型是一样的:
#include <iostream>
int main() {int a = 10;double b = static_cast<double>(a); double b1 = (double)a;return 0;
}
;若类型不相关,则报错。其安全性仅源于编译时的额外检查,运行时行为与强制转换一致。
自定义类型转换:
static_cast
也可用于内置类型到自定义类型的转换,前提是目标类定义了接受该内置类型参数的转换构造函数。若未定义相关构造函数,转换将失败:
#include <iostream>
class MyClass {
public:MyClass(int a) : _a(a) {} // 转换构造函数int getMember() { return _a; }
private:int _a;
};
int main() {int a = 10;MyClass b = static_cast<MyClass>(a); // 调用 MyClass(int) 构造函数std::cout << b.getMember() << std::endl; // 输出: 10return 0;
}
综上,static_cast
提升了类型转换的安全性(通过编译时检查)和代码可读性(明确表达转换意图)。
reinterpret_cast
实际编程中有时确实需要在不相关类型之间进行转换。static_cast
对此进行了限制,因此 C++ 提供了
reinterpret_cast
。该运算符允许进行不相关类型之间的转换,例如指针与整型之间的转换:
#include <iostream>
int main() {int a = 10;int* ptr = reinterpret_cast<int*>(a); // 将 int 值解释为 int* 地址return 0;
}
reinterpret_cast
放宽了类型限制,但也带来了更高的风险,因为它允许一些本质上不安全的转换(如上述将整数值直接视为指针地址)。其相对于传统强制类型转换的主要优势在于代码可读性:它清晰地表明代码正在进行一个可能不安全的、低层次的重新解释操作。
实现机制:reinterpret_cast
的底层实现机制与 C 风格强制转换相同。编译器识别到该运算符后,会直接将源表达式的比特位模式重新解释为目标类型,不进行任何类型检查。它本质上也是一个编译时指令标记。
const_cast
const_cast
用于移除对象的const
或 volatile
属性。它仅适用于指针、引用或指向对象类型的指针本身。其核心功能是操作对象的常量性(constness) 或易变性(volatility),而非数据类型本身。
#include <iostream>
int main() {const int b = 20;const int* a = &b; // a 是指向 const int 的指针int* ptr = const_cast<int*>(a); // 移除指针 a 的 const 属性// 警告:通过 ptr 修改 b 的值是未定义行为(UB),因为 b 本身是 constreturn 0;
}
关键限制:
const_cast
不能用于在不同数据类型之间进行转换(无论是否相关),它只操作类型限定符。- 切勿使用
const_cast
修改本身被声明为const
的原始对象(如上例中的b
),这会导致未定义行为(Undefined Behavior, UB)。它主要用于处理指向const
数据的指针/引用,而这些数据本身可能并非const
例如,接收const
指针参数的函数内部)。
实现机制:
const_cast
同样是一个编译时指令标记,最终会被编译器替换为等效的底层指令。
dynamic_cast
前三种类型转换运算符(static_cast
, const_cast
, reinterpret_cast
)本质上是对C语言强制类型转换((type)value
)的细化分类,它们在C风格强制转换的基础上增加了编译时的类型安全检查机制。而
dynamic_cast
则是C++特有的类型转换运算符,其核心应用场景在于处理具有继承关系的类类型之间的转换。
如果读者熟悉C++多态机制,应当了解触发多态所需满足的条件:需要一个定义了虚函数的基类,以及一个公有继承该基类的派生类;派生类中必须提供与基类虚函数声明完全相同的重写(override)。满足这些前提后,通过基类指针(或引用)指向基类对象或派生类对象,并调用该虚函数,即可触发动态绑定(运行时多态)。
多态的触发过程涉及类型转换。当使用基类指针指向派生类对象时(即向上转型/upcasting),这是编译器隐式允许且安全的操作,不会产生警告。此时通过基类指针调用被重写的虚函数,编译器会执行以下操作:定位到派生类对象中基类子对象部分存储的虚函数表指针(vptr),访问该虚函数表(vtable),在表中查找对应虚函数的实际实现地址(即派生类重写的版本),然后进行调用。
向上转型得到编译器的明确支持。那么,编译器是否同样支持向下转型(downcasting),即基类指针转换为派生类指针呢?
#include <iostream>
class Base
{
public:virtual void fun(){std::cout << "Base::fun()" << std::endl;}
private:int _a;
};
class Derive : public Base
{
public:virtual void fun() {std::cout << "Derive::fun()" << std::endl;}
private:int _b;
};
int main()
{Base b;Derive* ptr = &b; // 错误:无效的向下转型return 0;
}
如上例所示,编译器不允许这种直接的向下转型。原因在于类型转换的本质是为编译器提供一种新的视角来解释内存中的数据,而非改变内存布局本身。若允许将基类对象地址直接赋值给派生类指针,该指针会将其指向的内存视为一个完整的派生类对象布局(包含基类子对象和派生类特有成员)。然而,基类对象并不包含派生类的特有成员(如 _b
),访问这些成员将导致未定义行为(访问无效内存)。
dynamic_cast
的核心价值正是提供安全的向下转型能力。那么,向下转型的实际应用场景是什么呢?
考虑一个购票系统的例子。票分为不同类型,例如普通票(RegularTicket)和VIP票(VipTicket)。它们不仅价格不同,VIP票还享有额外待遇(如免费饮品)。用户通过手机扫码入场,可抽象为调用一个多态函数
checkIn
,所有票种通过基类指针 Ticket*
进行统一管理。
class Ticket {
public:virtual ~Ticket() = default;virtual void checkIn() const = 0; // 多态接口:验票入场
};class RegularTicket : public Ticket {
public:void checkIn() const override {std::cout << "普通票入场\\n";}
};class VipTicket : public Ticket {
public:void checkIn() const override {std::cout << "VIP票入场\\n";}// VIP特有方法void freeDrink() const {std::cout << "赠送VIP专属饮品\\n";}
};
扫码入场时调用公共接口 checkIn()
。若需识别VIP票并调用其特有方法 freeDrink()
,则需将基类指针 Ticket *
向下转型为具体的VipTicket *
类型。因为该指针实际指向的是一个VipTicket
对象(起始地址有效),转换为派生类指针后,即可按照派生类的内存布局访问其特有成员函数。
void processTicket(Ticket* ticket) {// 1. 统一调用多态接口ticket->checkIn();// 2. 尝试向下转型:识别VIP票VipTicket* vip = dynamic_cast<VipTicket*>(ticket);if (vip != nullptr) { // 转型成功 → 是VIP票vip->freeDrink(); // 调用VIP特有方法}
}
因此,dynamic_cast
向下转型的一个典型应用场景是:在调用基类定义的公共虚函数接口后,需要根据对象的实际类型(派生类),访问其特有的成员函数或成员变量 支持安全的向下转型,其与传统C风格强制转换或 进行向下转型的关键区别在于:dynamic_cast
在运行时增加了类型安全检查机制。该机制要求源类型(被转换的指针/引用类型)与目标类型之间必须存在有效的继承关系(即目标类型是源类型的派生类),并且基类必须至少包含一个虚函数(即具有多态性)。
dynamic_cast
的检查机制与前文提到的 static_cast
和 const_cast
有本质不同:后两者的检查发生在编译时,而 dynamic_cast
的检查发生在运行时(Runtime Type Information, RTTI)。
dynamic_cast
的运行时检查原理简述如下:
-
虚函数表(vtable)基础:如果一个类定义了虚函数(或其基类定义了虚函数),编译器会为该类生成一个虚函数表(vtable)。该类的每个对象在其起始位置(通常如此)包含一个指向其对应 vtable 的指针(vptr)。
-
运行时类型信息(RTTI):编译器会为每个包含虚函数的类生成一个
std::type_info
对象(或类似结构),其中包含该类的类型信息(如类型名称)#include <typeinfo>class type_info { public:virtual ~type_info(); // 虚析构函数bool operator==(const type_info& rhs) const noexcept; // 类型比较bool operator!=(const type_info& rhs) const noexcept; // 类型比较bool before(const type_info& rhs) const noexcept; // 用于排序const char* name() const noexcept; // 返回类型名称size_t hash_code() const noexcept; // C++11 添加的哈希值private:type_info(const type_info& rhs) = delete; // 禁止复制type_info& operator=(const type_info& rhs) = delete; // 禁止赋值 };
同时,在类的 vtable 中(具体位置依编译器实现而定,可能在 vtable 之前或之后),会存储一个指向其对应
type_info
对象的指针。 -
继承关系记录:
type_info
对象(或其关联结构)会记录该类的继承层次信息(基类列表)。 -
运行时检查过程:当执行
dynamic_cast<Derived*>(basePtr)
时:- 运行时系统首先检查
basePtr
是否为空。 - 通过
basePtr
找到其指向对象的 vptr。 - 通过 vptr 找到关联的
type_info
信息。 - 检查目标类型
Derived
是否与type_info
指示的当前对象类型相同,或者Derived
是否位于当前对象类型的继承层次中(即当前对象是Derived
的派生类对象)。 - 如果检查通过,则返回调整后的指针(可能涉及多重继承下的地址偏移);否则返回
nullptr
(对于指针类型)或抛出std::bad_cast
异常(对于引用类型)。
- 运行时系统首先检查
因此dynamic_cast
提供了安全的向下转型能力。关键点在于:进行向下转型(基类指针转派生类指针)时,该基类指针必须实际指向一个目标转换对象(或其派生类对象),才能成功转换。 这显著提高了代码的安全性、可读性和可维护性。
例子:
#include <iostream>
class base1
{
public:virtual void fun(){std::cout << "base::fun()" << std::endl;}
private:int _a1;
};
class base2 :public base1
{
public:virtual void fun(){std::cout << "derive::fun()" << std::endl;}
private:int _a2;};
class derive:public base2
{
public:virtual void fun(){std::cout << "derive::fun()" << std::endl;}
private:int _a2;
};
int main()
{derive b;base1* d=&b;derive* ptr =dynamic_cast<derive*>(d);std::cout << d << std::endl;std::cout << ptr << std::endl;return 0;
}
IO流
接下来的内容将是I/O流。在讲解C++的I/O流之前,我们需要先认识一下C语言的I/O机制。C语言的I/O提供了具体的函数,并依据I/O对象的不同进行了划分,例如与控制台交互的printf
函数,以及与文件交互的fopen
和fprintf
函数。
对于C++来说,由于它是一门面向对象的语言,与C语言提供具体函数不同,C++将I/O抽象为了流对象。要理解C++的I/O流,可以用一个例子来帮助我们理解。
我们知道,I/O操作涉及将数据从一个源对象发送到目标对象,或者接收目标对象发来的数据。数据本质上就是一个以字节为单位的字节流,类似于管道中的水流。流对象则代表水管的某一端,即数据的发送方或者接收方。发送方如同将瓶子装的水倒入水管,而接收方则如同打开水管,用瓶子接收水流。
这里的发送方本质上就是一个ostream
对象,而接收方就是istream
对象。我们C++中经常使用的cout
用于向显示器输出,以及cin
用于从键盘接收输入,它们本质上分别是ostream
对象和istream
对象的实例。
ostream
对象和istream
对象都继承自一个共同的基类,这个基类就是ios_base
。这里我们先从ios_base
讲起。我们可以将ios_base
理解为一个配置文件或者规则集,它规定了数据流的格式要求(例如接收数据的“容器”规格)。ios_base
封装了许多关键的标志位(flags),比如hex
(十六进和dec
(十进制,通常为默认)/ oct
(八进制),这些标志位规定了如果数据是整型,其输出或输入的进制格式。
由于ostream
和istream
都继承自ios_base
类,所以它们能够访问并使用ios_base
中所有的成员变量(主要指这些格式状态标志)。
显示器本质上对应着标准输出文件(通常指stdout
)。这里,我们要向标准输出文件写入的内容,就是要在显示器(终端)上打印的内容。此时我们需要调用流插入运算符(<<
)。流插入运算符为多种内置类型提供了重载版本。该运算符接收到要写入的数据后,首先会根据ios_base
所要求的格式(如进制、精度、宽度、填充字符等)对数据进行格式化处理(例如进制转换、添加空格等)。处理完成后,数据会以文本形式写入缓冲区,而不是直接写入标准输出文件中。因为直接写入文件需要调用操作系统提供的系统调用接口,会涉及到用户态到内核态的上下文切换,这会带来性能开销。因此,缓冲区中的数据会根据特定的刷新机制(如行缓冲、全缓冲等)最终刷新到标准输出文件中。流插入运算符最终返回的是cout
对象自身的引用(支持链式调用)。
我们可以在使用流插入运算符向显示器(标准输出文件)写入数据的过程中,去修改cout
对象中的属性(即
ios_base
的格式状态),从而动态地调整输出格式。
示例:动态修改格式:
#include <iostream>int main() {int num = 255;double pi = 3.1415926535;// 默认格式std::cout << "Default: " << num << ", " << pi << "\n";// 十六进制输出std::cout << "Hex: " << std::hex << num << "\n";// 科学计数法std::cout << "Scientific: " << std::scientific << pi << "\n";// 恢复默认std::cout << std::dec << std::defaultfloat;std::cout << "Restored: " << num << ", " << pi << "\n";return 0;
}
而对于 cin
,它则是通过流提取运算符( >>
)重载函数来接收来自键盘(对应标准输入文件,通常指 stdin
)的输入。数据会从标准输入文件读取到输入缓冲区。将数据填充到缓冲区也有特定的时机(例如行缓冲或全缓冲机制触发时)。当数据刷新(填充)到缓冲区后, cin
会根据 ios_base
当前设定的格式要求,对缓冲区中的内容进行解析和格式转换,然后将处理后的结果写入到最终的变量中,并且返回cin对象的引用。
部分读者可能会产生疑问:既然流提取运算符( >>
)的返回值是 std::istrean
对象的引用,而
while
循环的条件表达式需要接收一个 bool
值或整型值进行判断,那么代码是如何通过编译并正确执行的呢?
这涉及到 C++11 引入的一种特殊的运算符重载形式,它用于实现用户自定义类型向其他类型的显式或隐式转换。我们知道,C 风格强制类型转换的语法是 (type)val
。由于 ()
运算符在 C++ 中主要用于函数调用,因此要实现类型转换语义,需要定义特定的类型转换运算符重载函数:
operator type()//返回值类型就是type
{// ... 实现转换逻辑 ...
}
触发此类运算符重载函数调用的场景通常是隐式类型转换。当源对象与目标类型不匹配,但源对象所属的类定义了向该目标类型的转换运算符时,编译器会自动调用该函数。特别地,在需要布尔上下文的表达式(如
if
、 while
、 for
的条件判断,以及逻辑运算符的操作数)中,如果表达式结果对应的类定义了重载的
bool
转换运算符,编译器也会自动调用它。
以下代码示例演示了 operator bool
在条件判断中的隐式调用:
#include<iostream>
class myclass
{public:operator bool(){return true;}private:int a;
};
int main()
{bool ret=myclass();if(ret){std::cout<<"ret"<<std::endl;}return 0;
}
C++ 提供了用于文件输入和输出的流对象,分别为 ifstream (输入文件流)和 ofstream (输出文件流)。其中, ifstream 用于从文件读取数据(输入), ofstream 用于向文件写入数据(输出)。
我们可以采用二进制模式进行文件读写操作。使用 ofstream 进行二进制写入的基本步骤如下:
创建 ofstream 对象。
调用该对象的 open 成员函数打开目标文件。 open 函数需要指定文件的打开模式。
常用的文件打开模式标志如下:
模式标志 描述
std::ios::out 输出模式(对于 ofstream 是默认模式)
std::ios::app 追加模式(在文件末尾写入,保留原有内容)
std::ios::trunc 截断模式(若文件存在,则清空其内容)
std::ios::binary 二进制模式(避免文本模式下的字符转换)
这些模式标志本质上是位掩码(bitmask),每个标志对应一个特定的二进制位。 要同时使用多个模式,需使用按位或运算符
ostream& write(const char* s, streamsize count);
参数 s 指向要写入的数据缓冲区, count 指定要写入的字节数。
使用插入运算符 << : ofstream 继承自 ostream ,因此可以使用 ostream 的 << 运算符重载进行写入。此方法会根据操作数的类型进行格式化输出(例如,写入整数 123 会将其转换为字符序列 ‘1’ , ‘2’ , ‘3’ )。 当通过 ofstream 对象调用 << 时,数据将被写入该对象关联的打开文件,而非标准输出。过程通过流缓冲区( streambuf )的虚函数实现底层多态文件操作。
示例代码(使用 write 函数):
#include<iostream>
#include<fstream>
#include<string>
int main()
{std::ofstream file;file.open("file.txt",std::ios::out|std::ios::binary);std::string data("WangZhuo");file.write(data.c_str(), data.size());file.close();}
结语
这就是本篇关于类型转换和IO流的全部内容,感谢各位读者一直以来对我的c++系列的支持,那么之后我还更新c++系列的更多内容,希望能够多多关照,你的支持,就是我创作的最大动力!