目录
[2: 优化展示](#2: 优化展示)
摘要:
在C语言中,类型转换更像是一种"不得已而为之"的补救措施------当类型不匹配时,我们直接强转,我们只关心的只是"能不能转",而不太关注"转的过程中发生了什么"。
但在C++中,事情变得复杂。类型转换不再仅仅是语法层面的强制适配,而是对象生命周期的重新诠释。无论是内置类型向类类型的隐式构造,还是类类型之间的相互转换,每一次类型转换的背后,都会涉及到临时变量,这个临时对象没有名字,生命周期极短,并且具有常性,但其却承担着类型桥梁的关键角色。
然而,临时对象的引入也带来了一系列连锁反应:它触发了构造与析构的额外调用,也引发了拷贝的开销------尽管现代编译器会将过多的拷贝进行优化,但背后的优化机制仍需理解;它具有常性这一特征,引入了const引用绑定的权限规则,这将直接影响了我们编写代码时的接口设计;更进一步,它还考验着程序员对编译器优化行为的认知,因为不同编译器、甚至同一编译器下的不同优化级别,都可能产生截然不同的行为表现。
可以说,类型转换只是一个引子。它像一根线头,轻轻一拉,便能牵扯出C++中关于构造、拷贝、临时对象、引用绑定、编译器优化等一系列核心话题。这些话题看似独立,实则环环相扣。只要顺着这条逻辑链理下去,你便会发现,C++的类型系统并非杂乱无章,而是一个精密自洽的艺术!
本文将从类型转换出发,逐层拆解其背后的机制与设计意图。
一:类型转换概念及触发的条件
1:概念
类型转换概念:
将一种数据类型的值,在运算或赋值过程中,视为另一种数据类型来处理。 其本质是数据在内存中的表示方式或解释方式发生了变化。
①:隐式类型转换
在C语言中可能没有单独的学过类型转换,但有些地方早已涉及到了类型转换
最常见的就是当运算涉及两种不同类型时,系统会尝试把"范围小、精度低"的类型,转为"范围大、精度高"的类型,以保证数据不丢失,再执行运算。
例子:
cpp
#include <stdio.h>
int main() {
// 先单独看每个变量的类型大小
int a = 5;
double b = 2.5;
printf("int a 的大小: %zu 字节\n", sizeof(a)); // 4
printf("double b 的大小: %zu 字节\n", sizeof(b)); // 8
// 关键验证:a + b 这个表达式的结果是什么类型?
printf("a + b 表达式的大小: %zu 字节\n", sizeof(a + b)); // 8
// 如果是 int 应该是 4 字节,结果是 8 字节,证明已经被转成了 double
// 直接打印结果
double result = a + b;
printf("a + b = %f\n", result); // 7.500000
printf("-------------------\n");
// 验证 int + long
int c = 100;
long d = 200;
printf("int c 的大小: %zu 字节\n", sizeof(c)); // 4
printf("long d 的大小: %zu 字节\n", sizeof(d)); // 8(64位系统)
printf("c + d 表达式的大小: %zu 字节\n", sizeof(c + d)); // 8
// 结果是 8,证明 int 被提升成了 long
long sum = c + d;
printf("c + d = %ld\n", sum); // 300
printf("-------------------\n");
// 验证 float + double
float e = 1.5f;
double f = 2.5;
printf("float e 的大小: %zu 字节\n", sizeof(e)); // 4
printf("double f 的大小: %zu 字节\n", sizeof(f)); // 8
printf("e * f 表达式的大小: %zu 字节\n", sizeof(e * f)); // 8
// 结果是 8,证明 float 被提升成了 double
double total = e * f;
printf("e * f = %f\n", total); // 3.750000
return 0;
}

解释:
**①:**double范围大、精度高,所以a+b中即使a是整形,结果也会是double
**②:**同理long范围大、精度高,所以结果也是long
**③:**double范围大、精度高,所以一个float+double,结果为double
上述行为我们知道是类型转换,但是我们在C中是这么理解的:认为a隐式转换为了double,所以两个double相加,结果当然为double,同理c隐式转换为了long,e隐式转换为了double,但其实并不是ace本身发生了改变,而是涉及到临时变量......
②:显式类型转换(强转)
cpp
#include <stdio.h>
#include <stdlib.h>
int main() {
// 1. 浮点数 → 整数:直接截断,不是四舍五入
float f = 3.99f;
int i = (int)f;
printf("(int)3.99f = %d\n", i); // 输出 3,小数部分全丢
double d = -1.5;
int j = (int)d;
printf("(int)-1.5 = %d\n", j); // 输出 -1,同样是截断
// 2. 整数 → 浮点数
int a = 5, b = 2;
float result = (float)a / b;
// (float)a 把 5 强转为 5.0f,b 自动提升,得 2.5
printf("(float)5 / 2 = %f\n", result); // 输出 2.500000
// 3. 大类型转小类型:高位截断
int big = 0x12345678;
short small = (short)big;
printf("(short)0x12345678 = 0x%x\n", small);
// 输出 0x5678,高位的 0x1234 被丢弃
// 4. void* 转为具体类型指针(malloc 经典用法)
int* p = (int*)malloc(sizeof(int));
*p = 100;
printf("*p = %d\n", *p);
free(p);
// 5. 整数硬转为地址(嵌入式常用)
int addr = 0x1000;
int* ptr = (int*)addr;
// 把数值 0x1000 直接当作内存地址
printf("ptr = %p\n", ptr); // 输出 0x1000
// *ptr = 200; // 极危险!除非确定该地址有效,否则别这么做
return 0;
}

**解释:**同理,强转也是不会影响到变量本身的,比如f这个float变量,强转为int得到了3,但是f从始至终都是一个float,不会变成int,这里面同样涉及到临时变量
所以我们要知道,类型转换必定产生临时变量,且临时变量具有常性(只可读不可写),比如int类型值为1的变量a隐式转换为double类型变成1.000000,又或是int类型值为1的变量a强转为double类型,不管是隐式类型转换还是强转,都是不会改变变量a本身的,a一直是一个整形变量
完成类型转换且不影响变量本身这一效果,归功于临时变量,编译器会生成一个double类型临时变量,把1赋给double,所以临时变量就是1.000000了,后续参与计算的都是这个临时变量!所以在①中a和b相加的结果当然是一个double!因为是double类型的临时变量和double类型的b相加.....
❓️:临时变量不是具有常性吗?不可写,那为什么①中的a+b可以允许,此时double类型的临时变量加上b不会修改临时变量吗?
💡:a+b是不会影响到临时变量和b的,就好比3+2,得到的结果是5,3还是,2还是2,所以临时变量只是被读取了值和b的值进行相加(临时变量可以被读),从始至终临时变量都没有被修改!
**总结:**类型转换的语义,就是"用原操作数的值,生成一个目标类型的新值"。这个新值必须有个地方存放,那就是编译器生成的具有常性的临时变量,自始至终,不会影响到原操作数!
2:触发条件
类型转换不是随意就能进行的,只有当两个类型在逻辑或内存表示上存在某种联系时,转换才有意义,才会触发类型转换,否则无法触发类型转换!
我们上面代码大部分都是整形家族内的类型转换,同家族具有天然的联系,浮点数家族同理,而整形家族和浮点数家族之间也可以类型转换,因为二者都是表示一个变量的值;此外指针之间也可以互转,比如void*转换为int*,并且还改变了指针的解引用权限;整数也可以和指针互转,因为指针本来就是一个整形,其是某个地址的编号,所以整形转为指针,其实就是把一个整数值直接当作地址用。
反正想要类型转换,你起码得扯上点关系!比如石头可以转换为石锤,石刀,但你不可以让石头转换为铁锤,因为二者毫无关系....
所以譬如指针和浮点数,整形和结构体变量.....则毫无关联,是不会触发类型转换的!
cpp
#include <stdio.h>
struct Student {
char name[20];
int age;
};
int main() {
struct Student stu = { "Tom", 20 };
int a = 100;
//尝试1:整数直接赋值给结构体
stu = a; // ❌ 编译错误:incompatible types
//尝试2:结构体直接赋值给整数
a = stu; // ❌ 编译错误:incompatible types
// 尝试3:用 sizeof 看一下,大小也完全不对
printf("int 大小: %zu\n", sizeof(int)); // 4
printf("struct Student 大小: %zu\n", sizeof(stu)); // 可能是 24 或更大
return 0;
}

cpp
#include <stdio.h>
int main() {
float f = 3.14f;
int* p = NULL;
//尝试1:浮点数赋值给指针
p = f; // ❌ 编译错误:incompatible types
//尝试2:指针赋值给浮点数
f = p; // ❌ 编译错误:incompatible types
// 尝试3:用 sizeof 看,大小可能碰巧相同(都是4或8),但意义完全不同
printf("float 大小: %zu\n", sizeof(float)); // 4
printf("int* 大小: %zu\n", sizeof(int*)); // 可能是 4 或 8
return 0;
}

所以要想触发类型转换,一定是两个类型之间有点联系,要么是语义兼容性,要么是内存兼容性时,才允许转换。
二:权限
💡权限的规则:可平移,可缩小,不可放大
何为权限?
一个变量能读能写,读和写就叫做一种权限,权限决定了你能对这个变量做什么,所以如果一个变量不能写,你还尝试修改它,则必定报错,反之不能读,强行读取也会报错
所以权限是至关重要的,那什么叫权限平移,权限缩小,权限放大呢?
用一个能读能写的指针,去间接控制一个能读能写的变量。权限没有变大,也没有变小,完完整整平移过来了,就叫权限平移
用一个只能读不能写的指针,间接控制一个能读能写的变量,就叫权限缩小
用一个能读能写的指针,间接控制一个只能读不能写的变量,就叫权限放大
**注:**其中权限缩小和平移是语法允许的,而权限放大是不允许的!好比你是一个学生,你可以读书(平移),也可以休息(缩小),但你不能上台教书(放大),这是秩序!必须遵守!
例子1:权限平移
cpp
#include <stdio.h>
int main()
{
int a = 10;
int* p = &a; // ✅️ 权限平移
return 0;
}
**解释:**a是可读可写,指针p也是可读可写,所以权限平移✅️!
例子2:权限缩小
cpp
#include <stdio.h>
int main()
{
int a = 10;
const int* p = &a; // ✅️ 权限缩小
return 0;
}
**解释:**a是可读可写,指针p是可读不可写,所以权限缩小✅️!
例子3:权限放大
cpp
#include <stdio.h>
int main()
{
const int a = 10;
int* p = &a; // ❌ 编译错误 权限放大
//const int* p = &a; //正确写法 权限平移
return 0;
}

**解释:**a是可读不可写,但指针p是可读可写,所以权限放大❌️!
注:
**①:**在C语言中,我们使用指针时常常涉及到权限问题。因为指针是C中去间接控制变量的手段,而指针权限和变量权限之间就会产生权限问题,所以,C的权限问题和指针常常纠缠在一起
**②:**而在C++中,我们习惯用引用,引用是C++中间接控制变量的手段,所以引用权限和变量权限之间就会产生权限问题,所以,C++的权限问常常和引用纠缠在一起
引用的介绍和书写及性质不过多介绍了,博客:C++中的引用(看完前三点即可)
三:临时变量的值传递
通过上述的学习,我们知道但凡类型转换,则一定涉及到临时变量的产生,而临时变量是具有常性的,也就是只可读不可写的!所以需要注意权限问题,那为什么在第一大点的例子中,我们压根没有在乎临时变量具有常性这一点?难道不会带来权限的放大吗?!
本质是因为,我们根本没有用到临时变量具有常性这一点!压根没和临时变量的写权限扯上关系!
如下:
cpp
#include <stdio.h>
int main() {
int a = 0;//字面常量给a赋值
double b = 1.0;//字面常量给b赋值
double c = a;//临时变量给c赋值
return 0;
}
解释:
**①:**像0和1.0这种叫做字面常量,其和临时变量一样具有常性不可修改,但是我们在代码中貌似可以用一个变量a去接受它?a可读可写,只可读,不会权限放大吗?!实则不会,因为这句代码是读取了0的值,只使用了字面常量的读权限,然后把这个值赋给了a这变量,这是一种赋值行为;a 本身仍然是一个可修改的变量,只是它存了一个和常量0相同的值
**②:**所以double c = a;同理,产生了临时变量,但是只是读取了这个临时变量,然后把值赋给了c这个变量而已,同理c本身仍是一个可修改的变量,只是它存了一个和临时变量相同的值
**③:**换句话说,对于0或1.0这种字面常量,或这a产生的临时变量,虽然只能读不能写,但我们没有对字面常量或临时变量的内存做什么,我们没有对0这种字面常量进行取地址,也没有对a产生的临时变量进行取地址,然后用可读可写的指针来承载它,从而引发权限放大!我们仅仅对字面常量和临时变量做的事情,就只是读了一下它们的值(他们本身就是可读的),然后把值赋值给了另一个变量。这完全在权限允许的范围内。
**总结:**上述行为叫作临时变量的值传递,只是把临时变量的值给了变量,其中只使用了临时变量本来就拥有的读权限,而不涉及到临时变量的写权限!
**思考💡:**当然在C中我们也做不到对0这种字面常量进行取地址,会直接报错对常量取地址,其次我们也做不到对一个看不到的临时变量取地址,如下图


即使加上const修饰,试图形成权限平移,也不行:


但是在C++中, 我们不仅可以对字面常量进行引用,还可以对临时变量进行引用了,所以引用涉及到的权限问题比指针更多!后续再讲解!
四:临时变量的引用传递
💡权限的规则:可平移,可缩小,不可放大
1:引用下的权限
被const 修饰的变量,权限低于普通变量,因为前者只可读,后者可读可写,所以引用一个普通变量,则你的引用类型,可以被const修饰(缩小),也可以没有const修饰(平移),反之,引用一个const修饰的变量,你的引用类型,必须是被const修饰的引用(平移)
例子1:权限平移
cpp
int main()
{
// 普通变量 → 普通引用(权限不变)
int a = 10;
int& ref = a; // 可读可写 → 可读可写,权限一致
// const 变量 → const 引用(权限不变)
const int b = 20;
const int& ref2 = b; // 只读 → 只读,权限一致
return 0;
}
例子2:权限缩小
cpp
int main()
{
int a = 10;
// 权限缩小:普通变量 → const 引用
const int& ref = a; // 我有读写权,但我自愿只读着用,安全
a = 20; // 可以通过原变量修改
// ref = 30; // 错误!通过这个引用,我只能读,不能写
return 0;
}
例子3:权限放大(❌️)
cpp
int main()
{
const int b = 20;
const int& cref = b;// ✅️
// 权限放大:const 变量 → 普通引用,编译器直接报错!
int& ref = b; // ❌ 错误:b 是只读的,不能给一个可读可写的引用
// 同样,const 引用 转 普通引用也不行
int &ref2 = cref; // ❌ 错误:cref 只有只读权限,不能放大传递
return 0;
}

**解释:**不仅不可以用int类型的引用去引用一个const int。此外也不能把一个const int 类型的引用变成int,二者道理相同,都是权限放大!
在第三大点最后,我们说过在C++中, 我们不仅可以对字面常量进行引用,还可以对临时变量进行引用了,所以引用涉及到的权限问题比指针更多!现在对其进行讲解~
2:对字面常量进行引用
cpp
int main()
{
int a = 10; // 假设 a 是一个普通变量
// 1. ✅ 允许:const引用绑定到字面量常量
const int& ra = 30;
// 解析:30 是字面量,编译器会创建一个常性的临时变量来存它。
// ra 是这个常性临时变量的引用,权限平移(常性 ↔ 常性)。
// 2. ✅ 允许:const引用绑定到表达式结果
const int& rb = a * 3;
// 解析:a*3 的结果是一个常性的临时变量。
// rb 是这个常性临时变量的引用,权限平移。
// 3. ❌ 禁止:普通引用绑定到表达式结果
// int& rc = a * 3;
// 错误!a*3 的结果是常性临时变量,试图用普通(可读写)引用去引用它,权限放大!
// 4. ❌ 禁止:普通引用绑定到字面量
// int& rd = 30;
// 错误!30 对应的常性临时变量,同样不能绑定到普通引用,权限放大!
}

解释:
**①:**a*3 和 30 都是字面常量,具有常性(被视作 const 修饰的只读空间)
**②:**权限不可放大:试图用一个普通引用(可读可写权限)去引用一个常性字面变量(只读权限),就是权限放大,编译器必定报错!
**③:**反之,const int& 的引用,权限是只读的。用只读的别名去引用只读的临时空间,是完全匹配的权限平移,因此被允许。
**④:**所以在C中即使是const int* p= &1;也是不被允许的,但是C++中,const int& p = 1;是被允许的
3:对临时变量进行引用
既然是对临时变量进行引用,那必然是在类型转换的前提下!
例子:
cpp
int main()
{
double d = 12.34;
// ❌ 错误:权限放大
int& rd = d;
return 0;
}

解释:
**①:**警告不是因为类型不匹配,因为即使类型不同,但double 和int都是可读可写,应当权限平移不会报错
**②:**报错是因为从double的12.34到int类型的引用,这是类型转换,我们知道类型转换必定产生临时变量,所以编译器用一个int类型的临时变量来存储12.34的整形部分,然后再把临时变量赋给引用rd,而我们知道临时变量具有常性,也就是只可读不可写,把临时变量赋给一个int类型的引用,则权限放大!
**正确做法✅️:**使用const修饰rd,让rd的权限和d一致!
cpp
int main()
{
double d = 12.34;
// ✅ 正确:权限平移
const int& rd = d;
return 0;
}
问题来了,我们这个引用变量rd,引用的是谁呢?注意!引用的不是d,而是那个临时变量!
验证:
若rd引用的是d,则d和rd的地址应该一致
cpp
int main()
{
double d = 12.34;
// ✅ 正确:权限平移
const int& rd = d;
cout << &d << endl;
cout << &rd << endl;
return 0;
}

解释:
d和rd的地址明显不一致,所以rd引用的不是d,而是那个具有常性的临时变量,所以我们的rd要使用const修饰,和临时变量权限一致!
4:引用延长生命周期
在第3小点中,我们明白了可以对字面常量进行引用,可以对临时变量进行引用,注意权限就行,下面我们就这点继续探讨关于字面常量和临时变量生命周期被延长的问题!
①:延长字面常量的生命周期
cpp
int main()
{
//int& ra = 1;//❌️ 语法是对的,但权限放大
const int& ra = 1;//✅️权限平移
return 0;
}
解释:
❓️:问题来了,字面常量的生命周期是仅在当前行的,执行完当前行,字面常量就销毁了,那C++中的const int& ra = 1;这种写法,后续还能对ra进行使用吗?毕竟字面常量1的生命周期结束了呀?不会悬空引用吗?
💡:后续可以对ra进行使用!因为常量引用(const T&)能够延长字面常量生命周期,指的是普通的 30 这个字面常量,在所处行代码一结束生命就结束了,而你给它起了个别名ra,那编译器认为你确实想长期用它。于是规定:这个字面常量不能死,要活到 ra 这个引用结束为止。
②:延长临时变量的生命周期
cpp
int main()
{
double d = 12.34;
// ✅ 正确:权限平移
const int& rd = d;
return 0;
}
**解释:**同理,临时变量的生命周期也是在本行结束就结束的,但是rd在后续仍能使用,从侧面也证明了这个临时变量的生命周期被延长了,要活到 rd 这个引用结束为止
五:匿名对象
我们之前说临时变量的生命周期只在当前行,现在对其进行验证,在C++中,匿名对象就是临时变量的一种,所以我们通过验证匿名对象的生命周期,和const引用延长匿名对象的生命周期,来从侧面证明临时变量的生命周期和临时变量可被const引用延长生命周期!
怎么理解匿名对象是临时变量的一种呢?
很简单,它们的关系就像"水果"和"苹果":所有匿名对象都满足临时变量的定义:
无名无姓:都没有程序员给的名字,只能通过表达式"瞬间一瞥"。
生命短暂:都是编译器为了表达式的计算而临时创造的,生命周期默认只存在于当前语句。
提供只读的值:都是右值,主要任务就是提供它的值给外界使用。
所以匿名对象当然就是临时变量的一种!
下面我们举例代码验证匿名对象的生命周期,我们代码逻辑如下:
**①:测试1,**我们在构造和析构函数内都打印语句,让匿名对象那一行代码执行完,不仅调用构造还调用析构,就证明了匿名对象的生命周期仅在此行;
**②:测试2,**我们还对新的匿名对象进行const引用,当引用行代码执行完,只调用了构造,代表生命周期被延长;
**③:测试2,**并且我们把引用放在一个花括号域中,出此域,引用生命周期结束,则匿名对象生命周期也结束,毕竟临时对象的生命周期只延长到和引用的生命周期一致
cpp
#include <iostream>
class Tracker {
public:
int id;
Tracker(int id) : id(id) {
std::cout << "[构造] Tracker(" << id << ") at " << this << std::endl;
}
~Tracker() {
std::cout << "[析构] Tracker(" << id << ") at " << this << std::endl;
}
};
int main() {
std::cout << "========== 1. 无引用:用完即死 ==========" << std::endl;
{
Tracker(1); // 匿名对象,此行结束即死
std::cout << " 上行的匿名对象在执行完上行代码后,生命周期结束" << std::endl;
}
std::cout << "\n========== 2. const引用:续命 ==========" << std::endl;
{
std::cout << " 进入内层作用域" << std::endl;
const Tracker& ref = Tracker(2); // 这个匿名对象被续命
std::cout << " 匿名对象还活着,id = " << ref.id << std::endl;
std::cout << " 离开内层作用域,ref 将死" << std::endl;
} // ref 离开作用域,对象死亡
std::cout << " 已离开内层作用域" << std::endl;
return 0;
}

**解释:**和我们预期一致
**注:**匿名对象也是通过构造函数构造出来的,所以创建匿名对象时,参数是根据构造函数来给的,比如类A,A()代表有不需要参数的构造函数,A(1)代表有只需传递一个参数的构造函数,千万不要认为匿名构造只能A()这么写;匿名对象的写法,完全由构造函数决定,有多少种构造函数,就有多少种创建匿名对象的方式。
此外,既然都讲到匿名对象了,索性介绍一下匿名对象的用途,1是当想调用成员函数,但又不想显式实例化出对象的时候,就可以用匿名对象来调用成员函数;2是当一个函数的参数类型是类类型,此时又想将该函数设置缺省参数是,就可以使用匿名对象作缺省参数
例子1:匿名对象调用成员函数
cpp
class Solution {
public:
int Sum_Solution(int n) {
//...
return n;
}
};
int main()
{
// 匿名对象调用成员函数
Solution().Sum_Solution(10);
return 0;
}
**解释:**不想显式创建对象,所以用匿名对象调用成员函数
例子2:匿名对象作为缺省参数
cpp
void func(const A&aa = A()) //调用默认构造 也可以带参调用非默认构造
{}
**解释:**参数是A这个类类型,想给个缺省,直接给一个匿名对象即可,相比于创建一个全局的A类的对象,再把这个对象作为缺省参数,显然匿名对象更优秀
六:类类型的类型转换
在C++中,类有构造函数,构造函数的参数可能是内置类型,也可能是其他的类类型,也可能是内置类型和类类型综合在一起,所以我们实例化对象的时候,要注意传参
下面设计一个代码例子:Student学生类有三个成员变量,id 和 age 两个 int 成员以及Address类类型成员,而 Address 类自己有 zipCode 和 houseNum 两个 int 成员,所以这个例子让Student学生类既有内置类型成员变量,又有了类类型的成员变量
1:常规的实例化对象
cpp
#include <iostream>
using namespace std;
class Address {
public:
int zipCode;
int houseNum;
Address(int z = 100000, int h = 1) : zipCode(z), houseNum(h) {
cout << "Address构造函数被调用: " << zipCode << ", " << houseNum << endl;
}
};
class Student {
private:
int id;
int age;
Address addr; // 只有一个类类型成员,但Address类有两个成员变量
public:
// 全缺省构造函数
Student(int i = 0, int a = 18, Address ad = Address())
: id(i), age(a), addr(ad) {
cout << "Student构造函数被调用" << endl;
}
void display() const {
cout << "Student对象:" "学号: " << id << ", 年龄: " << age
<< ", 邮编: " << addr.zipCode << ", 门牌: " << addr.houseNum << endl;
}
};
int main() {
cout << "=== 三种构造方式 ===" << endl;
// 0个参数 全部使用缺省值
Student s1;
s1.display();
cout << endl;
// 1个参数(int)
Student s2(1001);
s2.display();
cout << endl;
// 2个参数(int + int)
Student s3(1002, 20);
s3.display();
cout << endl;
// 3个参数(int + int + Address)
Student s4(1003, 21, Address(200000, 5));
s4.display();
cout << endl;
return 0;
}

解释:
**①:**这种就是我们最常用的构造对象的方式,通过Student s1;或Student s2(1001);的形式来实例化出对象,需要注意的是Student s4(1003, 21, Address(200000, 5));,其中的Address(200000, 5)是先构造处理一个Address类的匿名对象,然后作为参数传递给Student类的构造函数
**②:**这里你也可以先显式创建出Address类的对象,然后把对象名作为参数传递给Student类的构造函数,但是没必要,因为这个对象的唯一目的就是作为参数,所以干脆直接用匿名对象
cpp
//没必要 使用匿名对象更优秀
Address a1(200000, 5);
// 3个参数(int + int + Address)
Student s5(1003, 21, a1);
s5.display();
cout << endl;
2:隐式类型转换实例化对象
那既然内置类型之间可以进行类型转换,那内置类型可以转换为类类型吗?当然可以!只要你的类类型构造函数支持即可!
cpp
#include <iostream>
using namespace std;
class Address {
public:
int zipCode;
int houseNum;
Address(int z = 100000, int h = 1) : zipCode(z), houseNum(h) {
cout << "Address构造函数被调用: " << zipCode << ", " << houseNum << endl;
}
};
class Student {
private:
int id;
int age;
Address addr; // 只有一个类类型成员,但Address类有两个成员变量
public:
// 全缺省构造函数
Student(int i = 0, int a = 18, Address ad = Address())
: id(i), age(a), addr(ad) {
cout << "Student构造函数被调用" << endl;
}
void display() const {
cout << "Student对象:" "学号: " << id << ", 年龄: " << age
<< ", 邮编: " << addr.zipCode << ", 门牌: " << addr.houseNum << endl;
}
};
int main() {
cout << "=== 三种隐式类型转换 ===" << endl;
// 1. int隐式转换
Student s5 = 1004;
s5.display();
cout << endl;
// 2. 花括号:int + int
Student s6 = { 1005, 22 };
s6.display();
cout << endl;
// 3. 花括号:int + int + {int, int}(Address隐式转换)
Student s7 = { 1006, 23, {300000, 8} };
s7.display();
cout << endl;
return 0;
}

解释:
**①:**这就是内置类型/类类型到类类型的隐式类型转换,我们使用=即可,只要你的类类型的构造函数支持这么传参Student s2(1001);,就支持直接=参数Student s2=1001;
**②:**需要注意的是写法,Student s4(1003, 21, Address(200000, 5));到Student s7 = { 1006, 23, {300000, 8} };后面隐式类型转换的第三个参数是Address的对象,所以你必须用花括号括起来,{300000, 8}会隐式转换为Address的对象,切记不能用括号(300000, 8),否则会变成逗号表达式,等同于(300000)
**③:**当然这里不会报错,因为Address的是全缺省构造,int300000 给 Address 构造函数,另一个参数使用缺省值 1其也会利用缺省值构造出对象;
**④:**所以无论如何,隐式类型转换时,类类型参数必须用 {} 括起来,这样才能正确匹配对应类的构造函数参数列表。
**总结:**任何类型都可以转换为类类型,只要类具有相应的构造函数即可
七:explicit
对于第六大点中的类,我能不能禁止隐式类型转换生成对象呢?可以,你只需在构造函数前⾯加explicit,该构造函数就不再⽀持隐式类型转换了
cpp
//Adress类中
explicit Address(int z = 100000, int h = 1) : zipCode(z), houseNum(h) {
cout << "Address构造函数被调用: " << zipCode << ", " << houseNum << endl;
}
//Student类中
explicit Student(int i = 0, int a = 18, Address ad = Address())
: id(i), age(a), addr(ad) {
cout << "Student构造函数被调用" << endl;
}

什么时候加 explicit?
| 场景 | 是否加 | 原因 |
|---|---|---|
| 单参数构造函数 | ✅ 强烈推荐 | 防止意外的隐式类型转换 |
| 多参数构造函数(C++11) | 🤔 看情况 | 防止花括号隐式初始化 |
| 拷贝构造函数 | ❌ 不加 | 拷贝构造是必要的 |
| 移动构造函数 | ❌ 不加 | 移动构造是必要的 |
| 有默认值的构造函数 | ✅ 推荐 | 防止 = {value} 的隐式转换 |
核心原则: 除非你明确需要隐式转换,否则单参数构造函数都应该加**explicit!** 这是C++编程的最佳实践,可以提高代码的安全性和可读性。
八:new中的类型转换
在C++中,我们常用new和delete,去替换C中的malloc和free,所以对于七大点中的类,我们如果new对象,常常这么书写:
cpp
#include <iostream>
using namespace std;
class Address {
public:
int zipCode;
int houseNum;
Address(int z = 100000, int h = 1) : zipCode(z), houseNum(h) {}
};
class Student {
private:
int id;
int age;
Address addr;
public:
Student(int i = 0, int a = 18, Address ad = Address())
: id(i), age(a), addr(ad) {}
void display() const {
cout << "学号: " << id << ", 年龄: " << age
<< ", 邮编: " << addr.zipCode << ", 门牌: " << addr.houseNum << endl;
}
};
int main() {
cout << "=== 正常的new===" << endl;
// 1. 默认构造
Student* p1 = new Student();
p1->display();
delete p1;
// 2. 显式传参构造
Student* p2 = new Student(1004, 22);
p2->display();
delete p2;
Student* p3 = new Student(1001, 18, Address(200000, 5));
p3->display();
delete p3;
return 0;
}

**解释:**new和malloc一样,都是匿名构造,再把在堆上开辟的空间的起始地址给目标指针
**思考💡:**既然在七大点中,Student s4(1003, 21, Address(200000, 5));可以写成Student s4 = { 1006, 23, {300000, 8} };那现在在new中,new Student(1001, 18, Address(200000, 5))可以写成new Student(1001, 18, {200000, 5})吗?当然可以,只不过七大点中两种形式是有名构造到有名隐式类型转换构造,比较明显,而现在是匿名构造到匿名隐式类型转换构造罢了!
所以在new中的隐式类型转换如下:
cpp
int main() {
cout << "\n=== new + 隐式类型转换 ===" << endl;
// 4. 单参数隐式转换(int -> Student)
Student* p4 = new Student(1003); // 等价于 new Student(1003, 18, Address())
p4->display();
delete p4;
// 5. 花括号隐式转换(两个int -> Student)
Student* p5 = new Student{ 1004, 22 };
p5->display();
delete p5;
// 6. 嵌套花括号隐式转换(int + int + {int,int} -> Student)
Student* p6 = new Student{ 1005, 23, {400000, 10} };
p6->display();
delete p6;
return 0;
}

❓️:重点,Student* p6 = new Student{ 1005, 23, {400000, 10} };和Student* p6 = new Student{ 1005, 23, (400000, 10)};有什么区别,都可以正确运行吗?
💡:二者都是隐式类型转换,前者是正确的写法,后者是错误的写法,在前文说过隐式类型转换时,类类型参数必须用 {} 括起来,所以错误写法中的 (400000, 10)是逗号表达式,等同于 (400000),所以和预期不符!
但是这里是可以允许成功的,因为Adress类的构造函数手机全缺省函数,所以即使你只传递了一个参数过去,另一个编译器会使用缺省的参数,从而能构造出对象,只不过第二个参数值和预期不符,这也从侧面证明了全缺省的构造函数可以为我们错误的书写进行补救,不至于直接报错,但也让我们更难以发现隐藏的错误!
所以隐式类型转换时,类类型参数必须用 {} 括起来,不管一个还是两个三个参数,如果参数里面又有类类型参数,则不管有几个,仍然有 {} 括起来,这样普通写法和隐式类型转换一眼就能分得清,还避免了逗号表达式造成的参数个数不匹配的问题;只不过单参的隐式类型转换,尝尝也会用(),因为只有一个参数,不会形成逗号表达式
❗️但是,在new中,隐式类型转换最方便的一点是,当我们申请的对象是多个的时候!!
如下:
cpp
int main() {
cout << "=== 普通new多个对象(显式构造) ===" << endl;
// 2. 普通new数组(显式传参)
Student* arr2 = new Student[3]{
Student(1001, 18, Address(200000, 5)),
Student(1002, 19, Address(300000, 8)),
Student(1003, 20, Address(400000, 10))
};
for (int i = 0; i < 3; i++) {
arr2[i].display();
}
delete[] arr2;
cout << endl;
cout << "=== 隐式类型转换new多个对象 ===" << endl;
// 3. 隐式转换new数组(花括号初始化)
Student* arr3 = new Student[3]{
{1004, 21, {500000, 12}},
{1005, 22, {600000, 15}},
{1006, 23, {700000, 18}}
};
for (int i = 0; i < 3; i++) {
arr3[i].display();
}
delete[] arr3;
cout << endl;
return 0;
}
cpp
// ✓ 正确:用 Student() 显式构造
Student* arr2 = new Student[3]{
Student(1001, 18, Address(200000, 5)),
Student(1002, 19, Address(300000, 8)),
Student(1003, 20, Address(400000, 10))
};
//理解上面写法的变种写法 但会先在栈上开辟 再复制到堆上
Student S1(1001, 18, Address(200000, 5));
Student S2(1002, 19, Address(300000, 8));
Student S3(1003, 20, Address(400000, 10);
Student* arr2 = new Student[3]{S1,S2,S3};
// ✗ 错误:逗号表达式,结果只是 Address
Student* arr2 = new Student[3]{
(1001, 18, Address(200000, 5)),
(1002, 19, Address(300000, 8)),
(1003, 20, Address(400000, 10))
};
解释:
**①:**我们普通new单个时候,是new Student(1001, 18, Address(200000, 5));,但是当我们new多个的时候,则数组内部每个元素都得是对象的类型,所以都得带Student,才能构造出对象,类似与Student* arr2 = new Student3{S1,S2,S3};
**②:**而隐式类型转换则简单多了,注意3{}这个花括号是数组的花括号,内部的花括号是隐式类型转换时,类类型参数的花括号
九:临时变量的因果
1:产生临时变量的场景
我们知道,类型转换必定产生临时变量,那还有什么场景可以产生临时变量吗?
有的,比如函数采用值返回的时候:
cpp
#include <iostream>
int add(int x, int y)
{
int sum = x + y; // sum 是局部变量
return sum; // ① 这里会把 sum 的值拷贝到一个临时变量中
} // ② sum 随函数结束而销毁
int main()
{
int result = add(3, 4); // ③ 临时变量再拷贝给 result
// 或者直接用:
std::cout << add(5, 6) << std::endl; // 临时变量直接参与表达式
return 0;
}
解释:
**①:**函数采用值返回时会产生临时变量,当函数返回的值是栈上开辟的,则会随着栈帧的销毁而销毁,所以编译器为了确保能把函数返回值给返回出来,会把sum的值拷贝到一个具有常性的临时变量中,让用户得到这个临时变量,用户常常创建变量来接受这个临时变量
**②:**所以用户一般读取这个临时变量的值,不会导致权限放大,因为没对临时变量进行写
**③:**所以必然的,想对函数返回值进行引用,必须const int& ret = add(3, 4);道理在上文已经讲过了,本质保持权限平移
此外需要注意💡:
**①:**这个函数采用值返回时会产生临时变量的设计是合理的!因为函数中的变量会随函数调用完成后,随着栈帧一起销毁;所以为了精确返回函数返回值,编译器采取了此种措施,并且可以拓展的一点是,只要函数采用值返回时,不管这个返回的值是否会随栈帧销毁而销毁,编译器都会为了确保正确放回,从而产生临时变量
**②:**也就是说,如果返回的值是static修饰的,生命周期虽程序,但若函数采用值返回时,仍会产生临时变量
**③:**所以函数返回时,出了函数作用域,如果返回对象还已经还给系统了,则必须使用传值返回,因为编译器会采取临时变量的措施,让其正确返回
那在C++中,也有类似的情况吗,有的!也是在函数值采用值返回到时候,只不过返回值是类类型

目前我们了解到产生临时变量的场景,1是类型转换,2是函数采用值返回,但其实,临时变量的生产场景还有很多,譬如表达式计算、传值传参等等......
2:临时变量带来的拷贝
临时变量的设计的确很优秀,支持了类型转换,确保了函数采用值返回时的正确性,还为表达式求值提供了必要的中间存储,那临时变量是否也有坏处呢?有的有的!
不管你是什么场景下产生的临时变量------隐式类型转换、函数返回值、表达式计算、传值传参------这个临时变量最终要么被拷贝给目标对象,要么被绑定到引用上。
但无论如何,临时变量的引入必然带来一系列连锁反应:
触发额外的构造与析构调用
在C++中,每产生一个临时对象,就意味着一次构造和一次析构,即使这个临时对象的生命周期极短。
引发拷贝的开销
从临时变量对象到最终目标,往往需要调用拷贝构造函数或赋值操作符。对于资源密集型对象(如容器、字符串、文件句柄等),这个拷贝可能代价高昂。
所以编译器看不下去了------这么多拷贝,能不能优化一下?
十:编译器对拷贝的优化
第十大点的目的在于建立这样一个认知:编译器会在保证程序语义正确的前提下,对构造和拷贝操作进行优化,以减少不必要的临时对象开销。需要特别注意的是,这种优化并非固定不变------不同的编译环境,甚至同一编译器下的不同优化级别,其优化策略和深度都可能存在差异。某些环境下,优化程度甚至会更激进。因此,我们不必深究具体环境下的优化细节,但必须清楚:优化是真实存在且可能影响程序行为的,这是理解构造/析构调用次数的前提。
1:优化规则
在Visual Studio 2019 Debug 环境下下,会产生以下优化:
如下:
**①:**构造+构造->构造
**②:**构造+拷贝构造->构造
**③:**拷贝构造+拷贝构造->拷贝构造
再怎么优化,你都会保留构造,因为如果对象都没有构造出来,谈何后面的拷贝?
**注意:**不同的编译器优化的效果可能会不同(博主是在 VS19 下的 Debug 版本下进行演示)
2: 优化展示
以下是一个用于测试的类A,其有构造函数,拷贝函数,赋值重载函数,构析函数,进入每个函数都会打印调用了一次 xx函数 的字眼,这样更能显式的看出调用的次数,以体现优化
cpp
class A
{
public:
//构造函数
A(int a = 0)
:_a(a)
{
cout << "A(int a)调用一次构造" << endl;
}
//拷贝函数
A(const A& aa)
:_a(aa._a)
{
cout << "A(const A& aa)调用一次拷贝" << endl;
}
//赋值重载函数
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)调用一次赋值" << endl;
if (this != &aa)
{
_a = aa._a;
}
return *this;
}
//构析函数
~A()
{
cout << "~A()调用一次构析" << endl;
}
private:
int _a;
};
①:在一行中的构造+拷贝
情况1:
cpp
int main()
{
A aa1 = 1;
return 0;
}
解释:
**①:**aa1是A类实例化的对象,而1是整形,这叫作类型转换
**②:**类型转换的规则如下:

**③:**所有的类型转换都是不会影响到变量本身的,所以如图中就是不会影响到1,并不是1真的变成了一个对象,而是1先作为参数进行了匿名构造,构造出一个临时对象(临时变量都是具有常属性的,即不能被更改),然后这个临时对象再去进行拷贝构造出了aa1(用一个已存在的对象去初始化 一个未存在的对象,叫作拷贝构造)
**④:**所以这里在同一行代码中进行构造+拷贝构造,所以优化成了单次的构造,如下图:

正如前文的规则所言,构造+拷贝构造->构造(构析是aa1出了main函数调用 了)
Q❓️:为什么不是直接进行拷贝构造呢?直接让1拷贝构造生成对象aa1不好吗?为什么要生成中间变量?不会压根没有中间变量的生成吧?
A💡:首先直接让1拷贝构造生成对象aa1这个想法就是错的,因为类型是不匹配的,所以必定会发生类型转换!而类型转换必定涉及到临时变量的产生!不妨反向验证一下!如果没有中间变量的生成,那从1到aa1就应该是变量到变量,那为何以下这样写不行呢?
cpp
A& aa1 = 1;
这样直接对生成的aa1进行了引用而已吗?权限是一致,但为什么就不行了呢?
报错如下❌️:

正确写法✅️:
const A& aa1 = 1;

**解释:**因为我们引用的不是1,而是这个临时变量,而临时变量具有常性,所以为了避免权限放大,所以我们的aa1也要被const修饰,从而和临时变量的权限一致,才不会报错!
情况2:
cpp
void func(A aa1)
{
}
int main()
{
//匿名构造仅是构造(不是类型转换,谈何构造加拷贝),然后再拷贝传参
func(A(2));
return 0;
}
解释:
**①:**func(A(2)),中的A(2)是一次匿名构造,调用了一次构造函数
②: A(2)构造出来的对象作为实参会给到func函数的形参,此处值传递,所以调用了一次拷贝函数
**③:**所以是一次构造 + 拷贝,但是是在同一行上连续进行的,所以优化成了:

情况3:
cpp
void func(A aa1)
{
}
//构造+拷贝构造->构造
int main()
{
//3 到 aa1 是隐示类型转换 先构造 在拷贝
func(3);
return 0;
}
**解释:**3直接作为参数给func函数的aa1接收,这是类型转换,如情况1中的意义,先进行构造,再进行拷贝,所以 应该是 在一行上进行 构造 + 拷贝,优化成了:

②:不在一行中的拷贝+拷
cpp
A func()
{
A aa;
return aa;
}
int main()
{
A aa1 = func();
return 0;
}
**解释:**func函数中 aa是一次构造,return aa,aa会拷贝构造出临时变量,所以这是一次拷贝 ,A aa1 中的 aa1 还未存在,此时去接受 func函数return 出来的对象,这又是一次拷贝,演示后两次拷贝的优化效果

**结果:**后两次拷贝优化成了一次
③:不在一行中的构造+拷贝
cpp
void func(A aa)
{
}
int main()
{
A aa1(1);
func(aa1);
return 0;
}
解释:
**①:**A aa1(1),是一次构造函数的调用
**②:**func(aa1),将aa1传给了func函数,是一次拷贝函数的调用
**③:**所以这是 构造 + 拷贝函数 ,但是不会被优化,因为其不是在一行上连续进行的
结果:

两次构析是aa 和 aa1 离开自己的作用域调用的
④:不在一行的的构造+构造
cpp
A func()
{
A aa;
return aa;
}
int main()
{
A aa2;
aa2 = func();
return 0;
}
解释:
**①:**A aa2是调用一次构造
**②:**func 则进入函数,A aa 是一次构造
**③:**return aa 是一次拷贝
④: 函数返回的对象给 aa2 是一次赋值(两个存咋的对象用 = 叫作赋值)
这个对前两次的构造进行展示,因为不是一行上的连续构造,所以不进行优化
结果:

3:编译器的行为
上述讲解的核心在于建立这样一个认知:编译器会在保证程序语义正确的前提下,对构造和拷贝操作进行优化,以减少不必要的临时对象开销。需要特别注意的是,这种优化并非固定不变------不同的编译环境,甚至同一编译器下的不同优化级别,其优化策略和深度都可能存在差异。某些环境下,优化程度甚至会更激进。因此,我们不必深究具体环境下的优化细节,但必须清楚:优化是真实存在且可能影响程序行为的,这是理解构造/析构调用次数的前提。
下图展示无优化,优化,更深优化,并进行解释:

解释:
**①:**在g++ -fno-dlide-constructors环境下,是不会作任何优化的
**②:**而在我们本文的vs2019debug环境下,是会做一定程度的优化
**③:**而在vs2022的debug环境下,优化程度更深
理解①:
g++ -fno-dlide-constructors环境下是不会作任何优化的,这意味着,临时对象一定会产生,那么临时对象生命周期结束时,也会调用析构函数!而我们上文都是有一定程度的优化的,所以临时变量的析构我们无法观察!
理解②:
在我们上文中,我们知道了return aa,aa会拷贝构造出临时变量,这是第一次拷贝 ,A aa1 中的 aa1 还未存在,此时去接受 func函数return 出来的对象,这是第二次拷贝,这两次拷贝会被优化为一次拷贝;换句话说,不就是aa直接拷贝构造出了aa2吗?
但这是违背了我们所学知识的,因为aa是栈上的临时变量,其会随f2函数栈帧销毁而销毁,怎么做到直接用aa来拷贝构造出main中的aa2呢?这是因为编译器让aa在结束之前就去拷贝构造main中的aa2了,所以不存在类似野指针的问题!
理解③:
vs2022的debug环境下,我们运行代码打印如下:

这意味着,在这种更深程度的优化下,我们连一次拷贝构造函数都没有调用,那问题来了!现在我们变量是aa和aa2,那么这个仅有的构造调用是构造的哪个变量?没有拷贝构造又是怎么生成另一个变量的?所以我们当前暂且认为,仅有的调用的构造函数是指的f2函数中aa的构造,而aa2不知道怎么产生的!
❓️:那编译器到底是怎么做的呢?
💡:编译器做法:直接构造出了aa2,然后让f2中的aa作为aa2的引用!这样只需调用一次构造,其次aa2在后续代码也可以被使用!
❓️:怎么验证呢?
💡:很简单,打印二者地址即可
cpp
A f2()
{
A aa;
cout << &aa << endl;
return aa;
}
int main()
{
A aa2 = f2();
cout << &aa2 << endl;
return 0;
}

❓️:为什么编译器选择构造main中的aa2,然后让aa作为aa2的引用,而不是构造aa,让aa2作为aa的引用呢?
💡:因为如果构造aa,而aa是栈上的临时变量,你aa2引用一个会随f2函数栈帧销毁而销毁的变量,会出现类似野指针的问题,导致后续aa2的使用会导致报错,所以当然选择构造aa2!
所以正如我们前文所言:"编译器会在保证程序语义正确的前提下,对构造和拷贝操作进行优化,以减少不必要的临时对象开销。",编译器不管怎么优化,一定都是并且程序语义正确,所以我们担心的问题都不存在,编译器会采取一些特殊的措施,在保证正确前提下,还达到了优化的效果!
十一:总结
**①:**从C中的类型转换的概念和触发条件,到讲解权限,再到临时变量的值传递不会触发权限放大,再讲解临时变量的引用传递,从而知道使用引用时应该注意权限,然后明白引用引用的是临时的变量,并且引用还会延长字面常量和临时变量的生命周期
**②:**通过匿名对象验证了临时变量的生命周期,以及临时变量真的会被引用延长生命周期;然后讲解类类型的隐式类型转换,明白了任何类型都可以转换为类类型,只要类具有相应的构造函数即可;不想构造函数进行隐式类型转换,则加上explicit关键字修饰即可
**③:**既然类对象可以通过隐式类型转换生成,那么在new中,new出对象的时候,自然也可以通过隐式类型转换new出对象,重点在于花括号的使用,避免参数变成逗号表达式出错,也感受到了构造函数为全缺省的好处,同时也更让我们难以发现逗号表达式引发的错误
**④:**明白了临时变量的因,也就是产生临时变量的场景,也明白了临时变量的果,也就是临时变量带来的大量拷贝赋值,所以编译器对临时变量带来的大量拷贝会进行优化,建立认知:编译器会在保证程序语义正确的前提下,对构造和拷贝操作进行优化,以减少不必要的临时对象开销,并且这种优化并非固定不变------不同的编译环境,甚至同一编译器下的不同优化级别,其优化策略和深度都可能存在差异。某些环境下,优化程度甚至会更激进。因此,我们不必深究具体环境下的优化细节,但必须清楚:优化是真实存在且可能影响程序行为的,这是理解构造/析构调用次数的前提。
📌 作者 shylyly
📃 首次发布 2026.7.23
❌ 最新修改 暂无
📜 声明 由于笔者水平有限,文中难免有疏漏或不妥之处,还望读者不吝赐教