目录
2.3.同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。
5.1.每次访问命名空间内的变量、类型或函数,都必须明确指定其所属的命名空间。
5.2.通过使用 using 关键字将特定的成员引入到当前作用域(部分展开命名空间成员)
5.3.使用using namespace 命名空间名称,进行全局展开。
(1)using namespace 命名空间名;指令的具体作用
3.2.缺省参数只能被指定一次,而且缺省参数不能在函数声明和定义中同时出现。
3.2.2.在函数声明(头文件)时对缺省参数进行赋值,而函数定义(源文件)时不用给缺省参数进行赋值
3.3.缺省值必须是常量、全局变量、或者能被计算为常量的表达式。
(2)函数重载时会自动匹配类型进行调用会不会使得程序运行变慢?
在C++中,引用的引入主要是为了解决C语言中的以下几个缺陷或限制:
2.4.引用一旦引用一个实体,再不能引用其他实体(即引用不能改变指向)
(3)类型3:用非const引用或者非const指针指向传值返回过程中产生的临时变量
(4)类型4:用非const引用或者非const指针指向在类型转换过程中产生的临时变量
4.1.引用使用场景1:用引用作参数(即输出型参数用引用来实现)
(1)方法1:用指针作参数(C语言解决方式)->可以改变实参的值
编辑(2)方法2:用引用作参数(C++解决方式)->可以改变实参的值
4.1.2.案例2:链表各个功能函数传参问题。例如:尾插函数。
4.1.3.案例3:顺序表各个功能函数传参问题。例如:尾插函数。
4.1.4.案例4:队列各个功能函数传参问题。例如:尾插函数。
5.2.传值返回、传引用返回(即值和引用作为返回值类型)的性能比较
1.1.C++提出的内联函数是为了解决C语言关于宏函数方面的缺陷
4.3.为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法。
4.4.auto在实际中最常见的优势用法就是跟的C++11提供的新式for循环,还有lambda表达式等进行配合使用。
(1)遍历数组方式1:for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
C++是在C的基础之上,容纳进去了面向对象编程思想,并增加了许多有用的库,以及编程范式
等。熟悉C语言之后,对C++学习有一定的帮助。
本章节主要目标:补充C语言语法的不足,以及C++是如何对C语言设计不合理的地方进行优化的,比如:作用域方面、IO方面、函数方面、指针方面、宏方面等。
知识点补充
1.C语言中的作用域
作用域的种类
- 局部作用域:在函数内部定义的变量具有局部作用域。这些变量仅在函数执行期间存在,函数调用结束后,这些变量就会被销毁。
- 全局作用域:在所有函数外部定义的变量具有全局作用域。这些变量在程序开始执行时创建,直到程序结束才被销毁。
作用域的影响
- 变量的使用:作用域决定了变量的可见性和可访问性。局部变量只能在定义它们的函数内部访问,而全局变量可以在整个程序范围内访问。
- 变量的生命周期:局部变量的生命周期通常较短,它们在函数调用时创建,在函数返回时销毁。全局变量的生命周期则与整个程序的运行周期相同。
2.以下是C++和C语言对全局变量和局部变量命名冲突的处理
注意:C++是兼容C语言的。
- 当局部变量和全局变量命名相同时,局部变量优先。这意味着如果在一个函数内部通过变量名访问变量,编译器首先会在局部作用域中查找该变量。
- 如果在局部作用域中没有找到对应的变量,编译器会继续在全局作用域中查找。
- 如果在全局作用域中也没有找到对应的变量,编译器将报错,指出未定义的标识符。
3.类型定义
类型的定义通常是在全局作用域中进行的,但也可以在局部作用域中进行。例如:typedef的类型重命名、结构体(struct
)、联合体(union
)和枚举(enum
)的定义等都可以在局部域和全局域中进行,但是在局部域中定义的只能在定义的那个局部域内部使用,在全局域中定义的则可以在整个工程中使用。
C++关键字
C++总计63个关键字,C语言32个关键字
命名空间
1.C++提出的命名空间是为了解决C语言以下几个缺陷
- 项目内部的命名冲突:当多个程序员在同一个项目中工作,并且他们各自编写的代码模块中存在相同的全局变量名、函数名、类型名(结构体、枚举、联合体)时,将这些模块合并时会发生命名冲突。
- 自己定义的函数名和库发生冲突:当程序员定义的变量名与C语言标准库或其他库中的变量名相同,会发生命名冲突。
-
全局变量和函数的命名冲突:在C语言中,如果两个不同的源文件定义了同名的全局变量或函数,那么在链接时会发生冲突。C++的命名空间可以将这些定义分隔开来。
-
宏定义的命名冲突:C语言广泛使用宏来定义常量或简单的函数。由于宏是在预处理阶段展开的,因此宏的命名冲突无法通过作用域来解决。虽然C++仍然支持宏,但命名空间可以用来定义具有相同名字的类、函数或变量,而不会与宏冲突。
-
库之间的命名冲突:当使用多个第三方库时,如果这些库定义了相同名字的函数或变量,那么在使用这些库时可能会遇到命名冲突。C++的命名空间可以将每个库的定义封装起来,从而避免冲突。
上面这些命名冲突会导致以下问题:
- 编译错误:编译器可能会因为无法区分同名的标识符而产生错误。
- 链接错误:链接器在尝试解析外部引用时可能会因为多个同名的全局标识符而失败。
- 不可预测的运行时行为:如果冲突没有在编译或链接阶段被发现,程序可能会在运行时表现出不可预测的行为,因为可能会错误地使用了不同的变量或函数。
为了避免这些冲突,C++通过引入命名空间机制,提供了一种更结构化的方法来避免这种类型的命名冲突。命名空间是C++为了更好地支持大型程序和代码库的组织、维护和重用而引入的一个特性。
2.命名空间的3种定义方式
在C++语言中,关键字namespace
用于创建一个命名空间。
定义命名空间格式:关键字namespace后面跟命名空间的名字,然后接一对{}即可,{}中即为命名空间的成员。
注意:一个命名空间就定义了一个新的作用域,命名空间中的所有内容都局限于该命名空间中,若想要使用命名空间中的内容则必须要指定命名空间。
2.1.正常的命名空间定义
//bit是命名空间的名字,一般开发中是用项目名字做命名空间名。
//注意:命名空间的名字可以使用自己名字缩写即可,如张三:zs
//正常的命名空间定义
namespace bit
{
// 命名空间中可以定义变量/函数/类型
int rand = 10;
int Add(int left, int right)
{
return left + right;
}
struct Node
{
struct Node* next;
int val;
};
}
2.2.命名空间可以嵌套定义
//命名空间可以嵌套定义
namespace N1
{
int a;
int b;
int Add(int left, int right)
{
return left + right;
}
namespace N2
{
int c;
int d;
int Sub(int left, int right)
{
return left - right;
}
}
}
2.3.同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。
//同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。
//一个工程中的test.h和test.cpp中两个N1会被合并成一个
//命名空间namespace N1在test.h头文件中定义
namespace N1
{
int Mul(int left, int right)
{
return left * right;
}
}
//命名空间namespace N1在test.cpp源文件中定义
namespace N1
{
int a;
int b;
int Add(int left, int right)
{
return left + right;
}
namespace N2
{
int c;
int d;
int Sub(int left, int right)
{
return left - right;
}
}
}
3.利用命名空间解决命名冲突的案例
注意:下面都是在创建好的C++项目中进行的。
3.1.案例1
(1)错误C++代码->错误原因:Test.cpp源文件中发生结构体Node类型命名冲突问题,并且报错信息是结构体Node类型在Test.cpp源文件重定义。
(2)解决方式:在命名空间中定义结构体Node类型
3.2.案例2
(1)错误C++代码->错误原因:Test.cpp源文件中发生全局变量min发生命名冲突问题,并且报错信息是全局变量min在Test.cpp源文件重定义。
(2)解决方式:在命名空间中定义全局变量min
3.3.案例3
(1)错误C++代码->错误原因:虽然同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。但是这里发生命名冲突的原因是当编译器把两个同名的命名空间合成一个命名空间的时候发现命名空间中的结构体Node类型重定义了。
(2)解决方式:若定义单个空间空间无法解决命名冲突问题,则通过嵌套定义命名空间来解决。
4.域作用符限定符'::'
4.1.使用域作用符限定符'::'访问全局变量
在C++中,当局部变量和全局变量发生命名冲突时,可以通过使用域作用符限定符'::'访问全局变量。如果局部变量和全局变量名称相同,可以通过在变量名前加上域作用符限定符'::'来访问全局变量。例如:
//全局变量
int a = 2;
void f1()
{
int a = 0;//局部变量
//由于全局变量和局部变量名字冲突则局部优先,所以访问f1函数中的局部变量a,打印结果a = 0.
printf("%d\n", a);
//采用::域作用限定符指定在全局域中访问全局变量a,打印结果:a = 2.
printf("%d\n",::a);
}
int main()
{
//由于main函数内部的局部域中没有找到局部变量a,则在全局域中访问全局变量a,打印结果:a = 2.
printf("%d\n", a);
f1();
return 0;
}
5.访问命名空间成员的3种方式
5.1.每次访问命名空间内的变量、类型或函数,都必须明确指定其所属的命名空间。
格式:命名空间名 :: 要访问的成员
5.2.通过使用 using
关键字将特定的成员引入到当前作用域(部分展开命名空间成员)
格式:using 命名空间名::要访问的成员;
(1)解析using 命名空间名::要访问的成员;部分展开的作用:
当你想要使用某个命名空间中的特定成员时,部分展开允许你直接使用该成员的名字,而不需要在每次使用时都加上完整的命名空间前缀。
注意: 部分展开只引入了命名空间中的特定成员,而不是整个命名空间的所有内容。这样可以避免由于命名空间中其他成员与当前作用域中的名称冲突而导致的潜在问题。
(2)案例:
代码解析: 通过 using
指令,我们可以在Test.cpp
文件中直接使用 Queue
结构体和与之相关的 QueueInit
以及 QueuePush
函数,而不需要每次都使用完整的命名空间作用域限定符 bit::
。使用部分展开而不是全局展开(即 using namespace bit;
),我们只引入了需要的特定成员,避免了将整个 bit
命名空间的所有内容都引入到当前作用域。这样做可以减少命名冲突的可能性,因为其他在 bit
命名空间中的成员不会影响当前作用域。
5.3.使用using namespace 命名空间名称,进行全局展开。
(1)using namespace
命名空间名;指令的具体作用
-
默认查找规则的变化: ① 默认情况下,编译器在查找变量、类型或函数时,会先在局部作用域中查找,然后是全局作用域,而不会自动在命名空间中查找。 ② 当使用
using namespace 命名空间名称;
后,编译器会将命名空间中的所有成员视为在全局作用域中定义的,因此在查找变量、类型或函数时,会默认在全局作用域中查找,包括刚刚展开的命名空间中的成员。 -
潜在的风险: 虽然使用
using namespace
可以简化代码,但它也可能引入以下风险:- 名称冲突:如果全局作用域中已经存在与命名空间内相同的名称,
using namespace
可能会导致名称冲突,使得编译器无法确定应该使用哪个名称。 - 代码维护困难:当命名空间中的成员很多时,全局展开可能会导致代码变得难以理解和维护,因为不清楚某个名称来自哪个命名空间。
- 名称冲突:如果全局作用域中已经存在与命名空间内相同的名称,
因此,虽然 using namespace
提供了便利,但通常建议在局部作用域或函数内部使用它,而不是在全局作用域中使用,以避免潜在的问题。
(2)案例:
代码解析:using namespace bit;
这行代码的作用是将命名空间 bit
中的所有成员引入到当前 Test.cpp
源文件的全局作用域中。通过使用 using namespace bit;
,在 Test.cpp
文件中,你可以直接使用 bit
命名空间中的任何类型、变量或函数,而不需要在它们前面加上 bit::
前缀。
注意:尽管全局展开简化了代码,但它也可能导致以下风险:①名称冲突:如果 Test.cpp
或它包含的其他头文件中已经定义了与 bit
命名空间中相同的名称,那么会发生名称冲突,编译器可能会产生错误或警告。②代码可读性降低:对于不熟悉代码的人来说,可能会不清楚某个名称是从哪个命名空间来的,这可能会降低代码的可读性和可维护性。
6.对命名空间进行总结
在C++中,命名空间是用于解决C语言中的一些缺陷,特别是关于命名冲突和全局作用域的问题。以下是对您提出的点的总结:
① 命名空间的域特性:
- C语言和C++都规定在同一个作用域内不能有相同的变量名。然而,在不同的作用域内可以有相同名称的变量。在C++中,命名空间定义了一个新的作用域,称为命名空间域。这意味着在不同的命名空间中可以有相同名称的变量、类型或函数,而不会发生命名冲突。
② 命名空间对变量生命周期的影响:
- 命名空间域确实只影响变量的名称解析,即如何引用变量,而不直接决定变量的生命周期。变量的生命周期仍然取决于它是定义在全局作用域(具有程序生命周期)还是局部作用域(例如在函数内部,具有块作用域生命周期)。如果在命名空间内部定义了全局变量(例如静态变量),则这些变量的生命周期将从它们被创建开始,直到程序结束。
③ 命名空间解决命名冲突:
- 利用命名空间,可以定义变量、类型(如结构体、枚举、联合体)等,这样即使不同的库或模块中有相同名称的实体,也不会发生冲突。这是因为命名空间提供了一种逻辑分组机制,使得相同名称的实体可以在不同的命名空间中独立存在。
④ 域作用限定符::
的使用:
- 域作用限定符
::
在C++中用于明确指定要访问的作用域。它不仅用于类型访问,也用于变量、函数、枚举常量等的访问。通过使用::
,可以消除命名冲突,明确指出要访问的是哪个命名空间或全局作用域中的实体。
C++输入&输出
#include<iostream>
// std是C++标准库的命名空间名,C++将标准库的定义实现都放到这个命名空间中
using namespace std;
int main()
{
cout << "Hello world!!!" << endl;
return 0;
}
1.说明
cout
用于输出数据到标准输出(通常是控制台),cin
用于从标准输入(通常是键盘)读取数据。使用cout标准输出对象(控制台)和cin标准输入对象(键盘)时,必须包含< iostream >头文件以及按命名空间使用方法使用std。- cout和cin是全局的流对象,endl是特殊的C++符号,表示换行输出,他们都包含在包含<iostream >头文件中。
- 3. <<是流插入运算符,>>是流提取运算符。
- 4. 使用C++输入输出更方便,不需要像printf/scanf输入输出时那样,需要手动控制格式。C++的输入输出可以自动识别变量类型。
- 5. 实际上cout和cin分别是ostream和istream类型的对象,>>和<<也涉及运算符重载等知识,这些知识我们我们后续才会学习,所以我们这里只是简单学习他们的使用。后面我们还有有一个章节更深入的学习IO流用法及原理。
2.注意
早期标准库将所有功能在全局域中实现,声明在.h后缀的头文件中,使用时只需包含对应
头文件即可,后来将其实现在std命名空间下,为了和C头文件区分,也为了正确使用命名空间,
规定C++头文件不带.h;旧编译器(vc 6.0)中还支持<iostream.h>格式,后续编译器已不支持,因
此推荐使用<iostream>+std的方式。
3.cout、cin可以自动识别变量类型
4.std命名空间的使用惯例
std是C++标准库的命名空间,如何展开std使用更合理呢?
1. 在日常练习中,建议直接using namespace std即可,这样就很方便。
2. using namespace std展开,标准库就全部暴露出来了,如果我们定义跟库重名的类型/对
象/函数,就存在冲突问题。该问题在日常练习中很少出现,但是项目开发中代码较多、规模
大,就很容易出现。所以建议在项目开发中使用,像std::cout这样使用时指定命名空间 +
using std::cout展开常用的库对象/类型等方式。
5.cout、cin的使用案例
缺省参数
1.缺省参数概念
缺省参数是声明或定义函数时为函数的参数指定一个缺省值。在调用该函数时,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参。
使用缺省参数的案例:
2.缺省参数分类
2.1.全缺省参数
注意:全缺省参数必须是给函数所有形参指定缺省值的。
2.2.半缺省参数
注意:半缺省参数必须是从右往左连续给函数形参指定缺省值的,但不是所有形参都指定缺省值 。
例1:
例2:
3.注意事项
注意:C语言不支持缺省参数。
3.1.半缺省参数必须从右往左依次来给出,不能间隔着给。
解析:当你为一个函数指定多个缺省参数时,你必须从最右边的参数开始提供缺省值。这是因为C++函数在解析时是从左到右进行的,如果左边有缺省参数而右边没有,那么在调用时会产生歧义。
3.2.缺省参数只能被指定一次,而且缺省参数不能在函数声明和定义中同时出现。
//注意:如果声明与定义位置同时出现,恰巧两个位置提供的值不同,那编译器就无法确定到底该
用那个缺省值。
//a.h
void Func(int a = 10);
// a.cpp
void Func(int a = 20)
{}
3.2.1. 错误案例
(1)例1:
错误原因:即使缺省参数在头文件(声明)和源文件(定义)中给不同值时也会发生报错,因为缺省参数重复定义。
错误代码:
(2)例2:
错误原因:缺省参数在头文件(声明)和源文件(定义)中给相同值时也会报错,因为缺省参数重复定义。
错误代码:
(3)例3:
错误原因:函数声明和定义的不一致性导致的链接错误。解析:在Test.h
头文件中,函数Func
被声明,但没有指定缺省参数。然后在Test.cpp
源文件中,函数Func
被定义,并且指定了一个缺省参数10。
这里的问题在于,Test.h
中的函数声明和Test.cpp
中的函数定义不一致。在C++中,如果一个函数在头文件中被声明,然后在源文件中被定义,那么定义必须与声明完全匹配,包括参数的数量和类型。虽然在C++中,可以在函数定义中提供缺省参数,但如果在声明中没有提到这些缺省参数,那么在链接时可能会出现问题。由于main.cpp
中包含的是Test.h
,它只知道Func
需要一个int
类型的参数,并不知道有一个缺省参数10
。当编译器尝试链接main.cpp
和Test.cpp
时,它会查找Func
的定义,但会发现声明和定义不匹配,因为main.cpp
中的调用没有提供必要的参数。
错误代码:
(4)上面3种错误案例统一的解决方法:
在头文件(.h文件)中声明函数时给缺省参数进行赋值,在源文件(.cpp文件)中定义函数时,不应该再次为缺省参数提供值。
3.2.2.在函数声明(头文件)时对缺省参数进行赋值,而函数定义(源文件)时不用给缺省参数进行赋值
- 声明(头文件)给缺省参数进行赋值: 这是指在头文件(.h文件)中声明函数时,应该为函数的缺省参数提供一个值。这样做可以让其他源文件在包含这个头文件时知道该函数的缺省参数是什么。例如:
// Test.h
void Func(int a = 20); // 在头文件中声明Func函数,并指定缺省参数a为20
- 定义(源文件)中不给缺省参数进行赋值: 这是指在源文件(.cpp文件)中定义函数时,不应该再次为缺省参数提供值。函数定义应该只包含函数的实际实现,而不需要重复声明中已经提供的缺省参数值。例如:
-
// Test.cpp #include "Test.h" void Func(int a) // 在源文件中定义Func函数,不指定缺省参数 { // 函数实现 }
这样做的好处是,可以避免在头文件和源文件中对缺省参数值的不一致定义,这可能会导致编译错误。遵循这种做法可以确保函数在整个程序中的接口是一致的。并如果缺省参数在声明和定义中被赋予不同的值,编译器将无法确定使用哪个值,从而产生错误。
3.3.缺省值必须是常量、全局变量、或者能被计算为常量的表达式。
3.3.1.缺省参数的缺省值类型
(1)缺省值必须是常量:
常量是指在程序执行过程中其值不会改变的量。这包括字面量(如数字、字符、字符串等)以及被const
关键字修饰的变量。例如:
void Func(int a = 10); // 10是字面量常量
(2)缺省值必须是全局变量:
全局变量是在所有函数外部声明的变量,它们具有全局作用域。全局变量可以是常量或非常量,但作为缺省参数时,通常要求是常量,因为非常量全局变量的值可能在程序运行期间改变,这会违反缺省参数的确定性。例如:
const int global_value = 20;
void Func(int a = global_value); // global_value是全局常量变量
(3)缺省值必须能被计算为常量的表达式:
这意味着缺省参数可以是任何能够编译时求值的表达式,其结果是一个常量值。这些表达式不能包含任何运行时才能确定值的变量或函数调用。例如:
void Func(int a = 1 + 2); // 1 + 2 是一个在编译时能计算为常量3的表达式
void Func(int a = sizeof(int)); // sizeof(int) 是编译时能计算的表达式
3.3.2.以下是一些不能用作缺省参数的例子
(1)运行时才能确定的变量,如局部变量:
void Func(int a = local_var); // 错误,local_var是局部变量
(2)运行时调用的函数返回值:
int get_value() { return 10; }
void Func(int a = get_value()); // 错误,get_value()是函数调用
3.3.3.总结
总结来说,缺省参数必须是在编译时就能确定其值的,以保证函数调用的确定性,并避免在程序运行时产生不确定性或错误。
4.缺省参数使用场景
解析下面这个栈初始化函数代码不足的地方
- 代码中
StackInit
函数在初始化栈时,使用固定大小的内存分配(如4个整数大小)对于不同的使用场景不够灵活。这种做法可能在某些情况下导致内存浪费,而在其他情况下则可能需要频繁扩容。 - 当我们预先知道栈需要存放的数据量时(如情况1中的st1需要存放100个数据),栈初始化函数
StackInit
使用固定的4个空间大小会导致频繁的扩容操作,这会影响性能并增加内存分配的复杂性。 - 当不确定栈需要存放多少数据时(如情况2中的st2),栈初始化函数
StackInit
可以分配一个合理默认大小的空间(如4个整数大小),这是一个折中方案,旨在平衡内存使用和扩容操作的需要。如果分配过多,可能会浪费资源;如果分配过少,则可能需要扩容来满足需求。
//cpp
#include <stdlib.h>
struct Stack
{
int* a;
int top;
int capacity;
};
void StackInit(struct Stack* ps)
{
ps->a = (int*)malloc(sizeof(int) * 4);
if (ps->a == NULL)
{
perror("malloc fail");
exit(-1);
}
ps->top = 0;
ps->capacity = 4;
}
int main()
{
Stack st1;//情况1:已知栈存放数据的个数。即栈st1最多要存100个数。
StackInit(&st1);
Stack st2;//情况2:未知栈存放数据个数。即栈st2不知道存放多少数据。
StackInit(&st2);
return 0;
}
代码优化
为了解决上面StackInit栈初始化
函数代码不足的问题,可以在StackInit
函数中使用缺省参数来提供更灵活的初始化选项。这样,我们就可以根据实际情况来决定初始化时分配的内存大小。
//cpp
#include <stdlib.h>
struct Stack
{
int* a;
int top;
int capacity;
};
void StackInit(struct Stack* ps, int defaultCapacity = 4)
{
ps->a = (int*)malloc(sizeof(int)*defaultCapacity);
if (ps->a == NULL)
{
perror("malloc fail");
exit(-1);
}
ps->top = 0;
ps->capacity = defaultCapacity;
}
int main()
{
Stack st1; //情况1:已知栈存放数据的个数。即栈st1最多要存100个数。
StackInit(&st1, 100);//不使用缺省参数的值,而是使用实参的值100,来开辟100个元素大小的空间。
Stack st2; //情况2:未知栈存放数据个数。即栈st2不知道存放多少数据。
StackInit(&st2);//使用缺省参数defaultCapacity = 4的值,来默认开辟4个元素大小的空间。
return 0;
}
函数重载
1.C++提出的函数重载是为了解决C语言以下缺陷
C++提出函数重载是为了解决C语言在函数命名上的一个缺陷,即C语言不允许在同一作用域内存在同名函数。这意味着在C语言中,即使两个函数执行非常相似的操作,但它们必须有不同的名字,如果它们接受不同类型或数量的参数。这种限制导致了以下问题:
-
代码可读性降低:由于不能使用相同的名字来表示功能相似的函数,开发者可能需要创造意义不明确的函数名,使得代码难以理解和维护。
-
代码冗余:为了处理不同类型的数据,可能需要编写多个几乎相同但参数类型不同的函数,导致代码冗余。
-
类型转换的滥用:在C语言中,如果想要调用一个只接受特定类型参数的函数,开发者可能不得不使用显式的类型转换,这不仅降低了代码的清晰度,也可能引入潜在的错误。
C++通过引入函数重载,允许在同一作用域中存在多个同名函数,只要它们的参数列表(参数的个数、类型或顺序)不同即可。这样做的优点是:
- 提高了代码的可读性和易用性:函数名可以反映出函数的功能,而不必关心具体的参数类型。
- 减少了代码冗余:可以编写一个函数处理多种数据类型,而不必为每种类型重写函数。
- 增强了类型安全:编译器可以根据参数类型来选择正确的函数,减少了因类型不匹配而导致的错误。
因此,C++中的函数重载主要是为了解决C语言在函数命名上的限制,使编程更为灵活和高效。
2.函数重载的概念
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这
些同名函数的形参列表(参数个数 或 类型 或 类型顺序)不同,常用来处理实现功能类似数据类型
不同的问题。
3.
3.1.参数类型不同
3.2.参数个数不同
3.3.参数类型顺序不同
4.注意事项
4.1.函数重载可以自动识别类型
(1)对函数重载可以自动识别类型进行解析:
①类型匹配:当调用一个重载函数时,编译器会检查调用点提供的参数类型,并将这些类型与重载函数的参数列表进行匹配。
②选择合适的函数:编译器会根据参数类型匹配的结果,选择最合适的函数版本。如果有一个函数版本与提供的参数类型完全匹配,那么这个函数就会被调用。
代码解析:
- 当
print(10)
被调用时,编译器识别到参数是一个整数,因此调用print(int value)
。 - 当
print(3.14)
被调用时,编译器识别到参数是一个双精度浮点数,因此调用print(double value)
。 - 当
print("Hello")
被调用时,编译器识别到参数是一个字符串字面量(实际上是const char*
类型的),因此调用print(const char* text)
。
(2)函数重载时会自动匹配类型进行调用会不会使得程序运行变慢?
函数重载本身并不会导致程序运行变慢。函数重载是在编译时解析的,这意味着在编译阶段,编译器会根据参数类型决定调用哪个重载函数。这个决策过程是在程序运行之前完成的,因此不会影响程序运行时的性能。
详细解析:
-
编译时决议:函数重载的解析是在编译阶段完成的,编译器会生成相应的指令来直接调用对应的函数。这意味着在运行时,程序不需要额外的类型检查或决策过程。
-
性能影响:运行时的性能主要取决于函数本身的实现,以及调用该函数的上下文。重载决策是在编译时做出的,不会在运行时产生额外的开销。
-
指令集:一旦编译完成,生成的机器代码包含了直接调用相应函数的指令。这些指令是直接指向内存中的函数地址的,执行速度与直接调用没有重载的函数相同。
总结来说,函数重载是一个编译时的特性,不会对程序的运行时性能产生负面影响。程序的性能主要取决于算法的效率、资源管理、系统架构等因素。
4.2.函数重载只能在同一个作用域中进行定义
解析:①在C++中,作用域可以是全局作用域、类作用域、命名空间作用域等。函数重载要求所有的同名函数必须在同一个作用域内声明。这意味着,如果两个同名函数在同一个类或者同一个命名空间中声明,它们可以构成重载关系。②函数不能跨作用域重载,即使它们的参数列表不同。如果在不同的作用域中定义了同名函数,即使它们的参数列表不同,它们也不会被认为是重载的版本,而是完全独立的函数。
案例:
(1)例1:全局作用域
// 全局作用域
void display(int value) {
// 显示整数值
}
void display(double value) {
// 显示双精度浮点数值
}
// 这两个函数是重载的,因为它们在同一个全局作用域内,并且参数列表不同。
(2)例2:命名空间域
namespace First {
void print(int x) {
// ...
}
}
namespace Second {
void print(double x) {
// ...
}
}
// First::print 和 Second::print 不是重载函数,因为它们属于不同的命名空间。
(3)例3:类作用域
class MyClass {
public:
// 类作用域
void display(const char* message) {
// 显示字符串
}
void display(int count, const char* message) {
// 显示整数和字符串
}
// 这两个成员函数是重载的,因为它们在同一个类作用域内,并且参数列表不同。
};
4.3.常见的一些错误写法的函数重载
例1:相同类型参数,但参数的顺序不同。注意:这个不叫函数重载,程序会直接发生报错。
5.C++支持函数重载的原理->名字修饰
为什么C++支持函数重载,而C语言不支持函数重载呢?
在C/C++中,一个程序要运行起来,需要经历以下几个阶段:预处理、编译、汇编、链接。
1.实际项目通常是由多个头文件和多个源文件构成,而通过C语言阶段学习的编译链接,我们可以知道,【当前a.cpp中调用了b.cpp中定义的Add函数时】,编译后链接前,a.o的目标文件中没有Add的函数地址,因为Add是在b.cpp中定义的,所以Add的地址在b.o中。那么怎么办呢?
2. 所以链接阶段就是专门处理这种问题,链接器看到a.o调用Add,但是没有Add的地址,就
会到b.o的符号表中找Add的地址,然后链接到一起。
3. 那么链接时,面对Add函数,链接接器会使用哪个名字去找呢?这里每个编译器都有自己的
函数名修饰规则。
4. 由于Windows下vs的修饰规则过于复杂,而Linux下g++的修饰规则简单易懂,下面我们使
用了g++演示了这个修饰后的名字。
5. 通过下面我们可以看出gcc的函数修饰后名字不变。而g++的函数修饰后变成 _Z+函数长度
+函数名+类型首字母。
- 采用C语言编译器编译后结果
结论:在linux下,采用gcc编译完成后,函数名字的修饰没有发生改变。
- 采用C++编译器编译后结果
结论:在linux下,采用g++编译完成后,函数名字的修饰发生改变,编译器将函数参数类型信息添加到修改后的名字中。
- Windows下名字修饰规则
对比Linux会发现,windows下vs编译器对函数名字修饰规则相对复杂难懂,但道理都
是类似的,我们就不做细致的研究了。
6. 通过这里就理解了C语言没办法支持重载,因为同名函数没办法区分。而C++是通过函数修
饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载。
7. 如果两个函数函数名和参数是一样的,返回值不同是不构成重载的,因为调用时编译器没办
法区分。
引用
在C++中,引用的引入主要是为了解决C语言中的以下几个缺陷或限制:
(1)指针的复杂性:
- C语言中的指针使用起来相对复杂,容易出错。指针需要进行解引用操作(使用
*
运算符)才能访问所指向的变量,而且还需要管理内存分配和释放。 - 引用提供了一个更简单的语法来间接访问变量,不需要显式的解引用操作,并且不需要担心指针的内存管理问题。
(2)函数参数的传递:
- 在C语言中,函数参数默认是按值传递的,这意味着传递大型结构体会产生副本,这会降低程序的效率。
- 引用允许函数参数按引用传递,这样可以避免不必要的复制,特别是对于大型对象来说,这可以显著提高性能。
(3)返回多个值:
- 在C语言中,函数只能返回一个值。如果需要返回多个值,通常需要使用指针或者全局变量。
- 引用可以用来返回函数内部创建的多个结果,例如通过引用参数返回多个值。
1.引用概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用开辟内存空
间,它(即引用)和它引用的变量共用同一块内存空间。
总的来说,引用的确不是新定义一个变量,而是给已存在的变量取了一个别名。
(1)引用定义的格式:
注意:引用类型必须和引用实体是同种类型的。
格式:类型& 引用变量名 = 被引用的变量;
(2)在C++中,&
字符有两种不同的用途
①引用的声明:当在变量类型后面使用&
时,它是用来声明一个引用。
int a = 10;//声明一个整型变量a
int &ref = a;//声明一个引用ref,它是变量a的别名
②取地址操作符:当&
单独使用,并且放在一个变量前面时,它是用来获取该变量的内存地址。
int a = 10;// 声明一个整型变量a
int *ptr = &a;//使用&来获取变量a的地址,并将其赋值给指针ptr
总的来说, 区分&
是用于声明引用还是用于取地址的关键在于它的位置:
- 如果
&
出现在类型后面(例如int&
),它表示引用的声明。 - 如果
&
出现在变量名前面(例如&a
),它表示取地址操作。
(3)给指针取别名
注意:①任何类型的数据都可以取别名,包括指针。②typedef只能对数据类型进行重命名,而引用是对变量取别名。
声明一个指向指针的引用:
//声明方式:
指针类型& 引用变量名 = 指针变量;
2.引用特性
2.1.引用在定义时必须初始化
(1)解析: 在C++中,引用是一种特殊类型的别名,它在声明时必须被初始化,并且一旦初始化后,就不能再重新绑定到另一个对象上。这意味着引用在创建时就必须要有一个明确指向的变量。
int a = 10;
int &ref = a; // 正确,引用在定义时被初始化为变量a
// int &ref; // 错误,引用必须初始化
(2)案例:引用没有初始化而发生报错
2.2.一个变量可以有多个引用(即一个变量可以有多个别名)
(1)解析:在C++中,一个变量可以有多个引用指向它。这意味着可以有多个引用名代表同一个变量,任何对这些引用的操作实际上都会影响原始变量。
int a = 10;
int &ref1 = a;
int &ref2 = a; // 正确,变量a可以有多个引用
ref1 = 20; // 变量a的值也会变为20
(2)案例:
2.3.引用的引用(即可以给别名取别名)
(1)解析:可以为已经存在的引用再创建一个新的引用,即引用的引用。这实际上是在创建一个新的别名,它仍然指向原始变量,而不是指向引用本身。
int a = 5;
int &ref1 = a;
int &ref2 = ref1; // ref2是ref1的别名,实际上仍然指向变量a
(2)案例:
2.4.引用一旦引用一个实体,再不能引用其他实体(即引用不能改变指向)
解析:这是指引用在初始化后,其指向的实体是固定的,引用不能被重新绑定到另一个实体上。这与指针不同,指针可以在任何时候改变其指向。引用的这个特性使得它在某些情况下比指针更安全,因为它不会意外地指向错误的内存地址。
int a = 10;
int b = 20;
int &ref = a; // ref引用了a
// ref = b; //错误,这并不是重新绑定ref到b。ref = b的作用实际上是将变量b的值赋给了变量a,而不是改变ref的引用目标。
(1)注意:在C++中,引用无法替代指针,因为引用一旦初始化后便无法改变其指向的对象,而指针则可以随时更新指向不同的内存地址。例如,在链表操作中,当链表进行尾插或者尾删时结点的指针域需要根据链表的变化动态调整指向,因此不能使用引用来代替指针域(即不能用引用来存放下一个结点的别名),否则将无法实现链表的尾插或尾删等操作。
3.常引用
注意事项:①指针和引用都有一个特点:在指针和引用进行赋值或者初始化操作时,权限可以缩小,但是权限不能放大。②权限可以平移,或者权限被缩小,但是不能把权限放大。
3.1.知识点:
在C++中,权限放大和权限缩小是指在不同类型的指针或引用之间赋值时权限的变化。
注意:只有指针或者引用对自己指向的空间的原本权限放大或者缩小时才会出现权限放大或者权限缩小的问题。①权限放大:即当指针或者引用指向的空间原本只有只读权限,但是指针或者引用对自己指向的空间却有读和写的权限则指针或者引用的权限就把空间原本的权限放大,则这就是指针或者引用对空间进行权限放大的问题。②权限缩小:即当指针或者引用指向的空间原本有读写权限,但是指针或者引用对自己指向的空间却只有读权限则指针或者引用的权限就把空间原本的权限缩小,则这就是指针或者引用对空间进行权限缩小的问题。③注意,权限放大或者权限缩小并不会改变空间原本的权限,只是我们在利用指针或者引用对空间进行操作时指针或者引用对空间的使用权限可能比空间原本权限大或者小,进而才会造成权限放大或者缩小的问题。
3.1.1.权限放大类型
对权限放大进行解析:
①注意当类型不匹配的数据进行赋值或者初始化操作时会存在隐式类型转换或者强制类型转换,但是在类型转换过程中会产生临时变量而这个临时变量的空间只有只读权限(即相当于这个临时变量是被const修饰的)。
②权限放大可以理解为:若存在一个空间只有只读权限(即被const修饰的空间)时,若指针或者引用可以指向这个只读空间而且指针或者引用具有读写权限(例如:非const指针、非const引用),当指针或者引用对这个只读空间进行写操作时指针或者引用就会对只读空间的权限放大。
(1)类型1:利用非const的引用指向const变量
描述:在C++中,尝试将一个const
类型的变量通过引用绑定到一个非const
引用上。
//错误代码
const int a = 10;
int& ref = a; //权限放大
错误原因:变量a是被const修饰的则const变量a的空间只有只读权限。但是现在却用非const引用指向const变量a的空间,而且非const引用对变量a的空间具有读写权限,当非const引用对const变量a的空间进行写操作时非const引用就会对const变量a空间的原本权限进行放大,因为const变量的空间只有只读权限没有写权限。
//正确代码
const int a = 10;
const int& ref = a; //权限保持
(2)类型2:用非const指针指向const的变量
描述:将一个指向const数据的指针赋值给一个可以修改数据的非const指针。
//错误代码
const int a = 10;
const int* p1 = &a;
int*p2 = p1;//权限放大
错误原因:注意,虽然指针p1和指针p2的类型不一样,则在赋值操作过程中会存在隐式类型转换而且类型转换过程中会产生只读的临时变量,但是指针p2并没有指向这个只读的临时变量而是指向变量a的空间,由于指针p2没有指向临时变量的空间则指针p2就不变把临时变量的空间权限进行放大的问题则就没有权限放大的问题出现(注意:只有指针或者引用指向的空间才有产生权限放大或者权限缩小的问题出现)。由于变量a是被const修饰使得const变量a的空间只有只读权限。现在却用非const指针p2指向const变量a的空间,而且对非const指针p2进行解引用后会对变量a的空间具有读写权限,当对非const指针p2进行解引用进而对const变量a的空间进行写操作时解引用的非const指针p2就会对const变量a空间的原本权限进行放大,因为const变量的空间只有只读权限没有写权限。
//正确代码代码
const int a = 10;
const int* p1 = &a;
const int*p2 = p1;//权限保持
(3)类型3:用非const引用或者非const指针指向传值返回过程中产生的临时变量
(即用非const引用或者非const指针接收函数传值返回的返回值 )
//错误代码:
//传值返回过程中把临时变量的权限放大
int Count()
{
int n = 0;
n++;
return n;
}
int main()
{
int& ret = Count();//这里会发生报错。权限放大。
return 0;
}
错误原因:Count函数在返回的过程中不是直接返回局部变量n的值的,而是Count函数在返回之前会在main函数中创建一个临时变量然后在Count函数在返回时把局部变量n的值存放在临时变量中并用临时变量的值进行返回。由于Count函数返回时是创建临时变量进行返回的而且临时变量具有常属性即临时变量的空间原本只有只读权限(即相等于临时变量被const修饰),但是main函数却用非const引用ret来接收Count()函数的返回值即用非const引用ret指向const临时变量,而且非const引用ret对临时变量的空间具有读写权限,当非const引用ret对const临时变量空间进行写操作时非const引用ret就会对const临时变量空间的权限进行放大,因为const临时变量的空间原本只有只读权限没有写权限。
//正确代码
int Count()
{
int n = 0;
n++;
return n;
}
int main()
{
const int& ret = Count();//权限保持。
return 0;
}
(4)类型4:用非const引用或者非const指针指向在类型转换过程中产生的临时变量
//错误代码
double d = 12.34;
int& rd = d;//权限放大
报错原因: 由于变量d是double类型数据而引用rd是指向int类型的数据,则在int& rd = d赋值操作过程中一定会存在隐式类型转换。在类型转换的过程中会产生临时变量来存放d转换为int类型数据的值(注意:在类型转换前后变量d原本的类型和值都没有发生改变),而且在类型转换完后非const引用rd指向这个在类型转换过程中产生的临时变量。由于临时变量具有常属性即临时变量空间只有只读权限,但是现在却用非const引用rd指向const临时变量的空间,而且非const引用rd对t临时变量的空间具有读写权限,当非const引用rd对const临时变量空间进行写操作时非const引用rd就会对const临时变量空间的原本权限进行放大,因为const临时变量的空间原本只有只读权限没有写权限。
//正确代码
double d = 12.34;
const int& rd = d;//权限保持
3.1.2.权限缩小类型
对权限缩小进行解析:
①注意当类型不匹配的数据进行赋值或者初始化操作时会存在隐式类型转换或者强制类型转换,但是在类型转换过程中会产生临时变量而这个临时变量的空间只有只读权限(即相当于这个临时变量是被const修饰的)。
②权限缩小可以理解为:若存在一个空间有读写权限(即被非cons修饰的t空间)时,若指针或者引用可以指向这个读写空间而且指针或者引用只有只读权限(例如:const指针、const引用),进而使得指针或者引用只能对这个读写空间进行读操作但是这个读写空间还有写操作权限,进而使得指针或者引用就会对读写空间原本的权限进行缩小。
(1)类型1:用const引用指向非const变量
int x = 1;
const int& y = x;//权限缩小
正确原因: 非const变量x的空间具有读写权限,但是现在用const引用y指向非const变量x空间,而且const引用y对变量x空间只有只读权限,进而使得我们无法使用const引用y对非const变量x空间进行写操作只能对非const变量x空间进行只读操作,但是非const变量x空间原本还有写权限,所以我们才说const引用y对非const变量x空间原本权限进行缩小。
(2)类型2:用const指针指向非const变量
int a = 10;
int* p1 = &a;
const int* p2 = p2;//权限缩小
正确原因:
由于变量a是被非const修饰使得非const变量a的空间具有读写权限。现在却用const指针p2指向非const变量a的空间,而且我们无法对const指针p2进行解引用来完成对非const变量a空间进行写操作,进而使得我们只能对非const变量a空间进行只读操作,但是非const变量a空间原本还有写权限,所以我们才说const指针p2对非const变量a空间原本权限进行缩小。
(3)总结:
权限缩小确保了通过指针或引用对数据的访问不会超出原始变量的权限限制,从而增强了代码的安全性和可靠性。
3.2.权限放大案例
3.2.1.引用把权限放大
(1)错误代码
错误原因:在C++中,const
关键字用于声明一个变量为常量,这意味着该变量的值在初始化后不能被修改。在下面代码中,变量a
被声明为const int
类型,其值被设置为10,因此a
是一个只读变量。然而,代码中创建了一个变量a的别名(即引用)ra。
这里的错误在于,ra
是一个普通的引用(非const
引用),它允许通过它来修改所引用的变量的值。但是由于a
是一个const
变量,它的值是不应该被修改的。当我们为const变量a声明一个非const的别名(即引用)ra时,实际上是在尝试“放大”变量a
的权限:原本只读的变量a
通过引用ra
变得可读可写。这种权限放大是不允许的,因为它违反了const
变量的定义和用途。const
变量的目的是确保其值在初始化后保持不变,而通过非const
引用ra
修改a
的值会破坏这一保证。
因此,程序在编译时会对引用ra
发生报错,原因是:
- 变量
a
是只读的,即它的值不能被改变。 - 引用
ra
作为变量a
的别名,如果允许ra
既可读又可写,那么实际上就绕过了a
的const
属性,使得可以通过ra
来修改a
的值。 - C++的类型系统不允许这种权限放大,因此编译器会报错,指出不能将
const
变量绑定到非const
引用上。
为了修正这个错误,应该使用const
引用来引用const
变量a
,如下所示:const int& ra = a;
这样,引用ra
也是只读的,与变量a
的const
属性保持一致,代码就可以正确编译和运行。
(2)修改后的正确代码
3.2.2.指针把权限放大
(1)错误代码
错误原因:在C++中,指针的类型决定了通过指针可以进行的操作。在代码中,p1
是一个指向const int
的指针,这意味着p1
可以指向一个整数,但是不能通过p1
来修改该整数的值。const int* p1 = NULL这里的const
关键字修饰的是int
,而不是p1
本身,所以p1
的值(即它所存储的地址)是可以修改的,但是它所指向的数据(即*p1
)不能通过p1
来修改。代码const int* p2 = p1将p1
赋值给p2
,其中p2
是一个普通的(非const
)指针。这里的问题在于,p2
是一个可以用来修改它所指向的数据的指针。将p1
赋值给p2
实际上是在尝试进行权限放大:虽然p1
不能修改它所指向的数据,但是通过将p1
赋值给p2
,p2
现在可以修改p1
所指向的数据,因为p2
是一个非const
指针。这种赋值是不允许的,因为C++的类型系统不允许将一个指向const
数据的指针赋值给一个可以修改数据的非const
指针。这样做会破坏const
的保护机制,使得通过p2
可以间接修改原本应该保持不变的const
数据。
因此,程序在编译时会对以下赋值操作发生报错:
p1
是一个指向const int
的指针,它不能用来修改它所指向的数据。p2
是一个指向普通int
的指针,它可以通过解引用来修改它所指向的数据。- 将
p1
赋值给p2
会使得p2
具有修改p1
所指向数据的权限,这违反了const
指针的定义。
为了修正这个错误,应该使用一个指向const int
的指针来接收p1
的值,
如下所示:const int* p2 = p1。这样,p2
也具有和p1
相同的只读权限,不会发生权限放大,代码就可以正确编译。
(2)修改后的正确代码
3.2.3.引用把函数返回过程中产生的临时变量的权限放大
(1)错误代码
错误原因:Count
函数返回的是一个临时变量的值,而不是变量本身。这个临时变量在函数调用结束后就会被销毁。当您尝试用非const引用ret
来接收这个返回值时,实际上是在尝试创建一个对临时变量的引用。然而,根据C++的规则,临时变量是具有常属性的,即它们不能被修改,因为它们是右值。在C++中,不允许声明一个非const引用绑定到一个右值上。这是因为非const引用需要绑定到一个可以修改的左值上,而右值是临时的、不可修改的。下面代码中将一个只读的临时变量(右值)通过非const引用ret
来接收,这实际上是一种权限放大的行为,因为非const引用允许通过它来修改所引用的值,而这在临时变量上是禁止的。
为了修正这个错误,应该使用const引用来接收Count
函数的返回值,这样就不会尝试修改临时变量,并且允许引用绑定到右值上:const int& ret = Count();
但是,需要注意的是,即使使用const引用,引用ret
也是危险的,因为它引用了一个临时变量,而这个临时变量在表达式结束时就会被销毁。因此,使用引用来接收函数的返回值通常是不安全的,除非函数返回的是它所引用的对象的一部分,或者返回的是一个具有持久生命周期的对象。对于Count
函数,最安全的做法是直接使用返回值,而不是通过引用。
总的来说,由于Count函数是传值返回,进而使得Count函数返回的是临时变量的值,但是临时变量是具有常属性(即可以认为这个临时变量被const修饰),若main函数用引用ret接收临时变量后由于ret有读和写的功能进而使得权限放大,若真正想用引用接收Count函数的返回值的话则引用必须定义成const int& ret = Count()。
(2)修改后的正确代码:
正确原因:Count
函数返回的是一个临时变量的值。这个临时变量是匿名的,它只在表达式中存在,并且具有常属性,意味着它不能被修改。通过使用const int&
来声明引用ret
,创建了一个只读引用。这意味着通过ret
不能修改它所引用的值,这与临时变量的常属性相匹配。因为ret
是const
引用,所以它不会尝试修改临时变量。这避免了权限放大问题,即不会允许通过ret
来修改原本不可修改的临时变量。由于ret
是只读的,它安全地引用了临时变量,直到ret
的生命周期结束(在这个例子中是main
函数的结束)。在这个过程中,ret
可以用来读取Count
函数返回的值,但是不能用来修改它。(注意:常属性是只能进行只读,不能进行修改的)。
3.2.4.引用把类型转换过程中产生的临时变量的权限放大
(1)错误代码
错误原因:double
类型的变量 d
不能直接绑定到一个 int
类型的引用 rd
上,因为它们的数据类型不匹配。当编译器尝试执行这个操作时,它会尝试通过隐式类型转换将 double
类型的 d
转换为 int
类型。这个转换过程中会产生一个临时变量,这个临时变量存储了转换后的整数值。这个临时变量具有常属性,即它是只读的,不能被修改。这是因为隐式类型转换生成的临时对象通常都是常量。尝试将这个只读的临时变量绑定到一个非常量引用 rd
上是不允许的,因为这会导致权限放大。非常量引用 rd
可以用来读取和修改它所引用的对象,但是临时变量是只读的,不允许修改。因此,代码 int& rd = d;
会报错,因为它违反了C++的类型系统和引用绑定规则。
总结来说,报错的原因是在尝试将一个 double
类型的变量通过引用绑定到一个 int
类型的引用时,发生了隐式类型转换,产生了具有常属性的临时变量,而这个临时变量不能绑定到一个非常量引用上。正确的做法是直接使用 int
类型的变量或者使用 const int&
引用来接收 double
类型变量的转换结果。(注意:常属性是只能进行只读,不能进行修改的)
(2)修改后的正确代码:
正确原因:当 double
类型的变量 d
被赋值给 const int&
类型的引用 rd
时,编译器会尝试进行隐式类型转换,因为 d
的类型与 rd
的类型不匹配。在类型转换的过程中,编译器首先会创建一个临时的 int
类型的变量。这个临时变量是用来存储从 double
类型转换到 int
类型的结果。由于 d
是 double
类型,它首先被转换为 int
类型,这个转换可能会涉及到取整操作(例如,如果 d
是 12.34,则转换后的整数值可能是 12)。这个临时 int
类型的变量具有常属性,即它是只读的,不能被修改。这是因为隐式类型转换生成的临时对象通常都是常量。const int& rd
是一个常量引用,它只能用来读取它所引用的对象,而不能用来修改。因此,将只读的临时 int
变量绑定到 const int&
引用 rd
上是允许的,因为没有权限放大。由于 rd
是常量引用,它不会尝试修改它所引用的临时变量,因此这个操作是安全的。
结论:代码 const int& rd = d;
是正确的,因为它遵循了C++的类型转换规则和引用绑定规则。通过使用 const
修饰引用 rd
,我们确保了引用只能用于读取,这符合临时变量的只读属性。如果尝试使用非常量引用(例如 double& rd = d;
),则会因为权限放大而报错,因为非常量引用允许修改它所引用的对象,而临时变量是不允许被修改的。
结论:类型转换(包括强制类型转换、隐式类型转换)都会创建临时变量,而类型转换时不是操作数进行类型转换而是创建的临时变量进行类型转换。例如:int i = 10;cout << (double)i << endl; 下面是(double)i 强制类型转换的过程:(double) i
的类型转换过程包括创建一个 double
类型的临时变量,将 int
类型的 i
的值转换为 double
类型并存储在临时变量中,然后使用这个临时变量来代替原始表达式。在这个过程中,原始变量 i
的类型和值保持不变。
总结:在C++中,无论是强制类型转换还是隐式类型转换,都涉及到创建一个临时变量来存储转换后的值。这个临时变量是必要的,因为它确保了类型转换的值有一个明确的位置存储,并且原始变量的类型和值保持不变。以下是一个具体的例子说明这个过程:
int i = 10;
cout << (double)i << endl;
在这个例子中,表达式 (double)i
是一个强制类型转换,它将 int
类型的变量 i
转换为 double
类型。以下是类型转换的过程:
- 编译器首先创建一个
double
类型的临时变量。 - 然后,将
int
类型变量i
的值(在这个例子中是 10)转换为double
类型,并将转换后的值存储在刚刚创建的double
类型临时变量中。 - 最后,这个
double
类型的临时变量被用来代替原始表达式(double)i
,并在cout
语句中使用。
在这个过程中,原始的 int
类型变量 i
的类型和值没有发生变化。变量 i
仍然是 int
类型,并且它的值仍然是 10。变化的是表达式 (double)i
的结果,它现在是一个 double
类型的值,等于 10.0。
总结来说,类型转换确实会创建一个临时变量,但这个操作不会影响原始变量的类型和值。临时变量仅用于存储转换结果,并在需要时替换原始表达式。
3.3.缩小权限案例
3.3.1.引用把权限缩小
代码正确原因:这里发生的是权限缩小,而不是权限放大。x
是一个普通的非const变量,意味着可以通过x
来修改其值。然而,通过创建一个const引用y
来引用x
,实际上是创建了一个只读的别名。即使y
是只读的,它也不会阻止通过变量x
直接修改其值。这里的关键点在于:
y
作为x
的const引用,不能用来修改x
的值。任何尝试通过y
赋值都会导致编译错误。- 但是,
x
本身仍然是一个普通的变量,可以通过x
来修改其值。
由于这里没有尝试通过y
来修改x
的值,所以代码是正确的。const int& y = x;
这行代码仅仅是将y
设置为一个指向x
的只读引用,而没有赋予y
修改x
的能力。这是完全合法的,并且是C++中常见的做法,用于确保函数参数不会被意外修改,或者当需要传递一个对象而又不希望它被修改时。
3.3.2.指针把权限缩小
代码的分析:p3
是一个指向 int
类型的指针,这意味着 p3
可以用来指向一个整数,并且可以通过 p3
来读取或修改它所指向的值(假设 p3
指向了一个有效的 int
类型的变量)。p4
是一个指向 const int
类型的指针,这意味着 p4
可以指向一个整数,但是不能通过 p4
来修改它所指向的值。p4
只能用来读取它所指向的值。
代码正确原因:将一个非常量指针(int*
)赋值给一个指向 const int
的指针(const int*
)是允许的,因为这是一种权限缩小的操作。p4
作为指向 const int
的指针,不能通过它来修改所指向的数据,即使 p3
可以(如果它不是 NULL
并且指向了有效的内存)。由于 p3
是 NULL
,它不指向任何数据,所以将 NULL
赋值给 p4
不会导致任何数据被修改的问题。这种赋值操作不会导致权限放大,因为 p4
的权限(只读)比 p3
的权限(读/写)要小。
3.4.由于不是引用和指针,则不涉及权限放大或者缩小的案例
(1)案例1:正确代码
正确原因:
(2)案例2:正确代码
4.引用使用场景
4.1.引用使用场景1:用引用作参数(即输出型参数用引用来实现)
注意:①用引用或者指针作输出型参数的本质是在函数内部通过形参的改变可以修改实参的值。在C++中,输出型参数一般是用引用来实现,而很少使用指针来实现。②由于引用一旦初始化后引用是不能改变指向的,而指针可以任意改变指针,所以链表、队列、二叉树等数据结构中的结点的指针域是不能用引用替代指针的(即不能用引用给下一个结点取别名或者说不能用引用指向下一个结点)。
4.1.1.案例1:交换两个变量的值
(1)方法1:用指针作参数(C语言解决方式)->可以改变实参的值
思路:传变量的地址,通过在函数内部对指针进行解引用来交换两个变量的值。
(2)方法2:用引用作参数(C++解决方式)->可以改变实参的值
思路:用引用做形参,就可以在函数内部通过修改形参(引用)的值进而改变实参的值。
解析:由于形参(引用)是实参的别名,则改变形参(引用)就可以改变实参的值。
4.1.2.案例2:链表各个功能函数传参问题。例如:尾插函数。
注意:指针也可以用引用。
传参问题:把指针plist指向的空链表通过不断的尾插创建成n个结点的链表时,这里的主要问题是一开始指针plist指向的链表是个空链表(即空指针),当利用尾插函数对空链表进行尾插时会涉及到改变实参一级指针plist的值,所以链表各个功能函数的参数必须满足通过修改形参的值进而直接改变实参的值的要求。解决方式如下所示:
链表类型定义:
//单链表存放的数据类型
typedef int SLTDataType;
//单链表结点的结构体类型
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode,*PSLTNode;
(1)方法1:用二级指针做参数(C语言解决方式)->可以在尾插函数内部通过对二级指针进行解引用直接改变实参一级指针plist的值。
//尾插函数
void SLTPushBack(SLTNode** pphead, SLTDataType x)//形参用二级指针
{
//…………省略
}
调试:
(2)方法2:用引用作参数,而且引用实体为一级指针plist(C++解决方式)->可以在尾插函数内部通过修改形参(引用)的值进而直接改变实参一级指针plist的值。
#include <iostream>
using namespace std;
//尾插函数
void SLTPushBack(SLTNode*& phead,SLTDataType x)//用引用作形参
{
//…………省略
}
调试:
代码优化:
#include <iostream>
using namespace std;
//void SLTPushBack(SLTNode*& phead,SLTDataType x)//形参用引用
//优化后的尾插函数写法
//注意:根据上面二叉树类型定义用typedef进行类型重命名可知,PSLTNode <=> SLTNode*,进而使得指针引用:PSLTNode& <=> SLTNode*&。
void SLTPushBack(PSLTNode& phead, SLTDataType x)//形参用引用
{
//…………省略
}
调试:
4.1.3.案例3:顺序表各个功能函数传参问题。例如:尾插函数。
传参问题:由于顺序表各个功能函数需要改变实参顺序表(结构体变量)中的成员变量的值,则顺序表各个功能函数的参数必须满足通过修改形参的值进而直接改变实参的值的要求。解决方式如下所示:
顺序表类型定义:
//顺序表中存储的数据类型
typedef int SLDateType;
//顺序表的结构体类型
typedef struct SeqList
{
SLDateType* a;
int size;
int capacity;
}SeqList;
(1)方法1:形参用指针(C语言解决方式)
//尾插函数
void SeqListPushBack(SeqList* ps, SLDateType x)//形参用一级指针
{
//…………省略
}
(2)方法2:形参用引用(C++解决方式)
#include <iostream>
using namespace std;
//尾插函数
void SeqListPushBack(SeqList& ps, SLDateType x)//形参用引用
{
//…………省略
}
4.1.4.案例4:队列各个功能函数传参问题。例如:尾插函数。
传参问题:由于队列各个功能函数需要改变实参队列(结构体变量)中的成员变量的值,则队列各个功能函数的参数必须满足通过修改形参的值进而直接改变实参的值的要求。解决方式如下所示:
队列类型定义:
//队列的数据类型
typedef int QDataType;
//队列链表的结点类型
typedef struct QueueNode
{
struct QueueNode* next;
QDataType data;
}QNode;
//队列的结构体类型
typedef struct Queue
{
QNode* head;//指针head指向队列队头数据。
QNode* tail;//指针tail指向队列队尾数据。
int size;//统计队列存储数据的个数
}Queue;
(1)方法1:形参用指针(C语言解决方式)
void QueuePush(Queue* pq, QDataType x)//形参用一级指针
{
//…………省略
}
(2)方法2:形参用引用(C++解决方式)
#include <iostream>
using namespace std;
void QueuePush(Queue& pq, QDataType x)//形参用引用
{
//…………省略
}
4.1.5.案例5:用前序遍历的结果创建链式二叉树。
二叉树类型定义:
//二叉树存储的数据类型
typedef char BTDataType;
//二叉树结点的结构体类型
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
(1)参数用指针(C语言解决方式)
//通过前序序列的字符数组"ABD##E#H##CF##G##"。注意:字符‘#’表示的是空结点
//注意:由于我们是用递归来实现构建二叉树的,由于我们一般不使用字符指针str遍历字符串,所以我们会传
一个下标i作为函数参数进行遍历字符串,但是由于函数是递归实现的而且每次递归调用后下标i就会被销毁进而在C语言中下标i在传参时必须传下标i的地址&i,若不传下标i的地址&i则每次递归时无法正确遍历字符数组。
BTNode* PrevOrderCreatTree(char* str, int* pi)//参数用指针
{
//若*str遇到字符‘#’,则此时二叉树前序遍历访问到了空结点,则此时无需创建空结点的空间,直接
//return。
if (str[*pi] == '#')
{
(*pi)++;
return NULL;
}
//若*str没有遇到字符‘#’,则说明此时二叉树前序遍历到了非空结点,则此时要创建结点。
//1.创建当前树的根结点
BTNode* root = BuyBTNode(str[(*pi)++]);
//2.把当前树的根结点与创建好的当前树的左右子树链接起来
//利用PrevOrderCreatTree函数创建当前树的左子树,并与当前树的根结点链接起来。
root->left = PrevOrderCreatTree(str, pi);
//利用PrevOrderCreatTree函数创建当前树的左子树,并与当前树的根结点链接起来。
root->right = PrevOrderCreatTree(str, pi);
//3.返回创建好的当前树的根结点地址
return root;
}
调试:
(2)参数用引用(C++解决方式)
#include <iostream>
using namespace std;
//通过前序序列的字符数组"ABD##E#H##CF##G##"。注意:字符‘#’表示的是空结点
//注意:由于我们是用递归来实现构建二叉树的,由于我们一般不使用字符指针str遍历字符串,所以我们会传
一个下标i作为函数参数进行遍历字符串,但是由于函数是递归实现的而且每次递归调用后下标i就会被销毁进而
在C++中下标i在传参时形参用引用(即i的别名)接受实参i,若形参不用引用接受实参i则每次递归时无法正确遍历
字符数组。
BTNode* PrevOrderCreatTree(const char* str, int& ri)//参数用引用
{
//若*str遇到字符‘#’,则此时二叉树前序遍历访问到了空结点,则此时无需创建空结点的空间,直接
return。
if (str[ri] == '#')
{
(ri)++;
return NULL;
}
//若*str没有遇到字符‘#’,则说明此时二叉树前序遍历到了非空结点,则此时要创建结点。
//1.创建当前树的根结点
BTNode* root = BuyBTNode(str[ri++]);
//2.把当前树的根结点与创建好的当前树的左右子树链接起来
//利用PrevOrderCreatTree函数创建当前树的左子树,并与当前树的根结点链接起来。
root->left = PrevOrderCreatTree(str, ri);
//利用PrevOrderCreatTree函数创建当前树的左子树,并与当前树的根结点链接起来。
root->right = PrevOrderCreatTree(str, ri);
//3.返回创建好的当前树的根结点地址
return root;
}
调试:
4.2.引用作参数的作用
(1)直接修改原始数据:在C中,当函数参数通过值传递时,传递的是实参的一个副本。因此,函数内部对参数的任何操作都不会影响原始数据。相反,如果使用引用类型作为参数,传递的是实参的引用,而非副本。这样,函数内部对引用的任何修改都会直接作用于原始数据。总的来说,用引用作参数可以通过修改形参的值直接修改实参的值。
(2)节省内存:由于引用不涉及创建实参的副本,因此不需要额外的内存来存储这个副本。这有助于减少内存使用。
(3)提高效率:复制大型对象是一个耗时的操作。通过使用引用,可以避免这种复制过程,从而提高函数调用的效率。
(4)传递非const对象:若要在函数内部修改传入的对象,必须使用非const引用作为参数。这允许函数对传入的对象进行修改。如果使用const引用,则函数只能读取对象,不能修改它,保证了数据的完整性。
4.3.输出型参数
注意:输出型参数用指针或者引用来实现的案例已经在4.1的用引用作参数中提到。
(1)输出型参数的用途:
总的来说,在C++中,输出型参数的用途是:它们通过将参数设置为指向数据的指针或引用,使得函数在执行结束后能够通过这些参数直接修改调用者所提供的实参。这样做的目的是为了实现函数能够返回多个结果,这是因为C++函数存在一个限制,即每个函数只能有一个直接的返回值。通过这种方式,输出型参数允许函数的执行结果不仅限于单一的返回值,而是可以在调用者的作用域中直接体现为多个变量的修改。
总的来说,若想通过修改形参的值进而改变实参的值,则就必须用指针或者引用作参数。
解析:
①初始化参数为指针或引用:在C++中,函数参数声明为指针或引用时,它们用于接收外部变量的地址或其引用,而不是复制变量。这表明,当函数内部对这些参数指向或引用的值进行修改时,实际上是在直接更改调用者提供的原始变量的值。
②修改调用者的变量:采用这种方法,函数有能力直接作用于实参,而不必依赖返回值。这种特性在函数需要输出多个结果时尤为关键,因为标准的返回机制仅允许返回单一值。
③达成返回多个结果的目标:鉴于C++函数的限制,即每个函数只能有一个直接的返回值,这在许多情况下并不足够。输出型参数提供了一种途径,使得函数可以通过多个参数来间接返回多个结果,每个参数对应修改调用者作用域中的一个特定变量。
(2)输出型参数与指针和引用的关系如下:
输出型参数与指针的关系:
- 输出型参数通过指针实现时,传递的是变量的地址。
- 函数内部通过解引用指针来修改指针所指向的变量的值。因此,函数内部通过解引用指针来修改外部变量的值。
输出型参数与引用的关系:
- 输出型参数通过引用实现时,传递的是变量的别名。而且引用不需要使用解引用操作符(*)来访问它所引用的变量。
- 函数内部直接使用引用来修改外部变量的值。
- 引用必须被初始化,并且一旦初始化就不能再引用其他对象,这使得引用在作为输出参数时更安全,因为它们不会意外地改变引用的目标。注意:当在C++中使用引用作为函数参数时,引用作为形参在接收实参时实际上是对引用进行初始化。引用在C++中是一种特殊类型的别名,它必须在声明时被初始化,并且一旦初始化,引用将绑定到特定的对象上,不能被重新绑定到另一个对象。
4.4.函数返回值的两种类型的对比
4.4.1.解析两种类型的函数返回过程:
(1)传值返回
①类型1:返回局部变量的值
以下是对Count
函数返回过程的详细解析:
函数栈帧的创建: 当Count
函数被调用时,系统为该函数分配一个栈帧。这个栈帧包含了函数的局部变量、返回地址、以及可能的其他信息。局部变量n
被声明在Count
函数的栈帧上。
局部变量n
的初始化与修改: 局部变量n
被初始化为0,然后通过n++
修改其值为1。
返回值的处理: 当执行到return n;
时,由于n
是一个局部变量,它的值需要被传递回调用者,但是不能直接传递n
的地址,因为一旦Count
函数执行完毕,其栈帧将被销毁,n
的空间将不再有效。
创建临时变量: 在返回之前,编译器通常会在寄存器中(如果返回值类型较小,如int
)或栈上(如果返回值类型较大)创建一个临时变量来存储n
的值。
函数栈帧的销毁: 当Count
函数执行完毕后,其栈帧被销毁,这表示n
的空间不再属于Count
函数,而是可能被系统重新分配给其他用途。
返回值的赋值: 在main
函数中,ret = Count();
这行代码会接收Count
函数返回的值。这个值是存储在临时变量中的,而不是直接从n
的空间中取得。
为什么不能直接返回n
的值: 不能直接使用n
的值作为返回值的原因是,一旦Count
函数的栈帧被销毁,n
的空间就不再属于程序的有效内存区域。访问已经被销毁的栈帧空间是未定义行为,可能会导致程序崩溃或其他不可预测的结果。
函数栈帧销毁与赋值的顺序: 在实际调用函数的过程中,Count
函数必须先完成其执行,包括返回值的处理,然后其栈帧才能被销毁。销毁栈帧后,控制权返回到调用者(main
函数),此时可以通过之前设置的临时变量将返回值赋给ret
。销毁栈帧是为了正确地恢复调用者的栈帧,这样可以通过调用者的栈帧基指针(ebp)找到ret
变量的位置并进行赋值。
总结: Count
函数返回值的过程涉及将局部变量n
的值保存到一个临时变量中,然后通过这个临时变量将值传递回main
函数中的ret
变量。这样做是为了确保在Count
函数栈帧销毁后,返回值依然有效,并且可以被正确地赋值给ret
。
②类型2:返回静态变量的值
以下是对Count
函数返回过程的详细解析:
函数栈帧的创建:当Count
函数被调用时,系统为该函数分配一个栈帧,用于存储局部变量、返回地址以及其他必要的信息。
局部变量n
的初始化与修改:n
是一个静态局部变量,它在程序的静态存储区中分配空间,而不是在栈上。它在声明时被初始化为0,并且在每次Count
函数调用时,其值都会递增。
创建临时变量: 在函数返回之前,编译器通常会在寄存器中(如果返回值类型较小,如int
)或栈上(如果返回值类型较大,如大型结构体)创建一个临时变量来存储要返回的值。对于大型数据类型,如大型结构体,这个临时变量用于存储结构体的副本,因为大型数据类型不适合直接通过寄存器传递。
复制数据到临时变量: 如果返回值类型较大,静态变量n
的内容会被复制到这个临时变量中。这个复制过程可能涉及内存拷贝操作。由于n
是静态的,其值在函数调用结束后不会销毁,但仍然需要复制到临时变量中以供返回。
使用临时变量返回: 当函数结束时,临时变量中的数据会被用来作为返回值。对于小型数据类型,如int
或指针,由于它们的大小适合直接通过寄存器传递,编译器可能会选择直接将值放入寄存器而不创建临时变量。
在调用者中接收返回值: 在main
函数中,会创建一个相应的变量(如ret
)来接收返回值。如果返回值是通过寄存器传递的,那么ret
将直接从寄存器接收值。如果返回值是通过栈上的临时变量传递的,那么ret
会在栈上分配空间,并且临时变量中的数据会被复制到ret
中。
总结: 编译器可能会在返回之前在寄存器或栈上创建临时变量来存储返回值,这取决于返回值类型的大小。对于小型数据类型,返回值通常直接通过寄存器传递。对于大型数据类型,则可能在栈上创建临时变量,并执行复制操作。
(2)传引用返回
以下是对Count
函数返回过程的详细解析:
在Count
函数中,静态变量n
被声明并初始化为0。由于n
是静态的,它在程序的生命周期内只初始化一次,并且在Count
函数调用结束后仍然存在。每次调用Count
函数时,n
的值都会递增。当Count
函数执行到return n;
语句时,它返回的是静态变量n
的引用,而不是n
的拷贝。在C++中,当函数返回引用时,通常不会创建临时变量来存储返回值。相反,返回的引用直接指向原始的静态变量n
。在main
函数中,Count
函数被调用,并且返回的引用被赋值给ret
变量。
(3)错误程序
解析:Add函数返回的是局部变量c的别名,而main函数是用引用ret来接收Add函数的返回值的,进而使得ret是局部变量c的别名。
注意:如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用
引用返回,如果已经还给系统了,则必须使用传值返回。
(4)总结:传值返回可以理解为函数返回 返回值的拷贝(临时变量)、传引用返回可以理解为函数返回 返回值的别名(引用)。
4.4.2.对传值返回、传引用返回的总结
①传值返回: 当函数通过值返回时,它会创建一个临时变量来存储要返回的值。这个临时变量是调用函数的返回类型的实例。在返回局部变量时,这个局部变量的值会在函数返回前被复制到这个临时变量中。一旦函数执行完毕,局部变量就会被销毁,但临时变量仍然存在,直到它的值被赋给接收返回值的变量。这个过程涉及到值的复制,对于大型对象来说可能会非常耗时。
②传引用返回: 传引用返回可以避免这种复制操作。当函数返回引用时,它实际上返回的是对现有变量的一种引用或别名,而不是该变量的一个副本。这意味着,返回引用时,不会创建临时变量来存储返回值。相反,返回的引用直接指向原始变量。
传引用返回的作用:①减少拷贝:对于大型对象或需要避免复制的类型,传引用返回可以显著提高性能。②修改函数返回值:若函数用引用进行返回,则调用者可以修改返回对象。(即调用者可以通过函数返回的别名(引用)来修改函数的返回值)
传引用返回使用条件:只有当返回的变量在函数作用域之外仍然存在时,才能使用传引用返回。这通常适用于静态变量、全局变量、从函数外部传入的引用、malloc函数开辟的。
4.5.引用使用场景2:用引用作函数返回值
注意:由于输出型参数可以使用指针和引用来解决进而使得引用不是作为输出型参数解决问题的唯一手段,从而引用真正的价值并不是在作输出型参数上。引用真正的价值在于用引用作返回值,而且用引用作返回值是不可替代的。
传引用返回的正确使用条件是:假设函数要返回的是变量a的值。当函数需要返回变量a的值时,只有当变量a在函数作用域之外仍然有效,即其生命周期超出函数调用范围,才能使用引用返回。这意味着变量a可以是以下几种情况之一:
- 全局变量:其生命周期贯穿整个程序运行期间。
- 静态变量:其生命周期从声明开始直到程序结束。
- 外部函数栈帧中的变量:其生命周期长于当前被调用函数的栈帧。
总结:函数在返回变量a时,若变量a在函数作用域结束后依然存在,即可采用传引用返回。这种情况适用于变量a是全局变量、静态变量,或是属于上一层函数栈帧的变量。关键在于,只要变量a在当前函数栈帧销毁后依然保持有效,便可以使用引用返回,从而返回变量a的别名而非其副本。这样做可以避免不必要的拷贝,提高程序效率。
4.5.1.案例1:调用者可以修改返回对象。
(即调用者可以通过函数返回的别名(引用)来修改函数的返回值)
#include <assert.h>
#include <iostream>
using namespace std;
#define N 10
typedef struct Array
{
int a[N];//定义一个静态数组
int size;
}AY;
//引用返回的作用
//1、减少拷贝。
//2、调用者可以修改返回对象。(即调用者可以通过函数返回的别名(引用)来修改函数的返回值)
//定义一个PosAt函数,这个函数可以随意访问结构体ay中数组a第i个位置的元素。
int& PosAt(AY& ay, int i)
{
assert(i < N);//利用assert判断要访问的位置i是否发生越界
//由于PosAt函数是用引用AY& ay作参数使得结构体ay出了PosAt函数作用域之后结构体ay
//依然存在则可以使用引用返回,这里返回的是结构体ay中的数组a第i个位置对象的别名。
return ay.a[i];
}
int main()
{
AY ay;
//通过修改函数返回的引用(别名)的值来对结构体ay中数组a的所有元素进行初始化
for (int i = 0; i < N; ++i)
{
//用引用作函数返回值,则函数实际返回的是变量的别名(引用),使得可以通过修改函数返回的别名(引用)进而修改函数的返回值。
//解析:由于PosAt函数是用引用AY& ay作参数使得PosAt函数返回的是结构体ay中的数组a第i个位置对象的别名,则此时可以通过对PosAt函数返回的别名进行赋值操作后来修改结构体ay中的数组a第i个位置对象的值。
PosAt(ay, i) = i * 10;
}
//打印结构体ay中的数组a所有元素
for (int i = 0; i < N; ++i)
{
cout << PosAt(ay, i) << " ";
}
cout << endl;
return 0;
}
5.传值、传引用效率比较
总结:
(1)以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直
接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效
率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
(2)传值返回和传参有点类似在。C++规定,在函数返回时不会直接把函数内部的变量的值直接返回而是会产生一个临时变量来存放函数内部的返回值,然后在主调函数要调用返回值时才会调用这个临时变量中的返回值。
5.1.传值传参、传引用传参的性能比较
#include <iostream>
using namespace std;
#include <time.h>
struct A
{
int a[10000];
};
void TestFunc1(A a)
{}
void TestFunc2(A& a)
{}
void TestRefAndValue()
{
A a;
//以值作为函数参数
size_t begin1 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc1(a);
size_t end1 = clock();
//以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc2(a);
size_t end2 = clock();
// 分别计算两个函数运行结束后的时间
cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;
cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}
int main()
{
TestRefAndValue();
return 0;
}
测试:
5.2.传值返回、传引用返回(即值和引用作为返回值类型)的性能比较
#include <time.h>
#include <iostream>
using namespace std;
struct A
{
int a[10000];
};
A a;//定义全局变量。全局变量既可以传值返回,也可以传引用返回。
//值返回
A TestFunc1()
{
return a;
}
//引用返回
A& TestFunc2()
{
return a;
}
void TestReturnByRefOrValue()
{
//以值作为函数的返回值类型
size_t begin1 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc1();
size_t end1 = clock();
// 以引用作为函数的返回值类型
size_t begin2 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc2();
size_t end2 = clock();
// 计算两个函数运算完成之后的时间
cout << "TestFunc1 time:" << end1 - begin1 << endl;
cout << "TestFunc2 time:" << end2 - begin2 << endl;
}
int main()
{
TestReturnByRefOrValue();
return 0;
}
测试:
5.3.总结
通过上述代码的比较,发现传值和指针在作为传参以及返回值类型上效率相差很大。
6.引用和指针的区别
注意:引用和指针的区别要在语法、底层两方面进行区别。
6.1.在语法上的区别
在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
注意:从语法层面来看,引用实际上并不单独分配内存空间,它仅仅是目标对象的别名;而指针则不同,它在语法上会分配一个内存空间,用于存储其指向的对象的地址。其次,从汇编语言的层面来看,引用的实现细节表明,尽管在高层语法中看似不需要单独空间,但在底层实现时,引用实际上还是会分配内存空间来存储地址信息。
6.2.在底层实现上的区别
int main()
{
int a = 10;
//引用
int& ra = a;
ra = 20;
//指针
int* pa = &a;
*pa = 20;
return 0;
}
引用和指针的汇编代码对比:
解析:在C++语言中,引用的概念在语法层面被设计得非常简洁,它被视为另一个变量的别名,不需要单独的内存空间来存储。然而,当我们深入到底层实现,即汇编代码层面时,情况有所不同。在底层实现上,引用实际上是需要分配内存空间的。这是因为引用在底层是以指针的形式来实现的。通过比较指针和引用的汇编代码,我们可以观察到,无论是指针还是引用,它们在汇编层面上的表现是相同的即都会开辟一个空间来存储地址信息。具体来说,当我们查看汇编代码,会发现指针pa
和引用ra
在底层处理上是等效的。这表明引用ra
在底层确实需要分配空间,并且本质上它就是一个指针。总的来说,引用从汇编角度看,引用是用指针实现的。
总结:虽然在语法层面上引用看起来更为简单,不需要显式地管理内存,但从编译器的视角来看,引用的实现复杂度与指针相同。在编译器的处理过程中,引用实际上就是一个指针。
6.3.引用和指针的不同点
注意:①指针和引用的区别从底层、语法角度这两个角度去理解、去区别。 ②引用的底层就是指针。③若引用和指针都用作参数或者作返回值则它们的效率都是一样的。
(1)引用概念上定义一个变量的别名,指针存储一个变量地址。
(2) 引用在定义时必须初始化,指针没有要求。
(3) 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何
一个同类型实体。
(4) 没有NULL引用(即没有空引用),但有NULL指针(即有空指针)。
(5) 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32
位平台下占4个字节)。
(6) 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小。
(7) 有多级指针,但是没有多级引用。
(8) 访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
(9) 引用比指针使用起来相对更安全。
内联函数
-
C++认为C语言的宏存在诸多问题,尤其是宏定义的常量和宏函数。
-
对于宏定义的常量:
- C++推荐使用
const
关键字来定义常变量,这样可以保证类型安全和值的不变性。 - C++还推荐使用
enum
关键字来定义常量,这样可以创建一组相关的常量,同时保持类型安全。
- C++推荐使用
-
对于宏函数:
- C++建议使用
inline
关键字来声明内联函数,以替代C语言中的宏函数。 - 内联函数保留了宏函数的性能优势,同时提供了函数调用的语义,包括类型检查、作用域规则和调试能力。
- C++建议使用
-
总体来说,C++的推荐做法(即C++有哪些技术替代宏)是:
- 使用
const
和enum
替代宏定义的常量。 - 使用
inline
函数替代宏函数(即短小函数定义换用内联函数来代替宏函数),以提高代码的可读性、可维护性和安全性。
- 使用
1.宏
1.1.C++提出的内联函数是为了解决C语言关于宏函数方面的缺陷
-
类型安全:C语言的宏函数不进行类型检查,可能导致类型错误。C++的内联函数会进行类型检查,保证了类型安全。
-
作用域规则:宏函数在预处理时展开,不遵守作用域规则,可能导致名称冲突。内联函数遵循C++的作用域规则,避免了名称冲突的问题。
-
调试困难:宏函数在预处理阶段展开,无法在宏上设置断点或进行单步调试。内联函数是编译器识别的函数实体,可以正常进行调试。
-
参数多次求值:宏函数的参数可能会被多次求值,可能导致性能问题和意外的副作用。内联函数的参数只在函数调用时求值一次,避免了这些问题。
-
代码可读性和维护性:宏函数的定义可能复杂且难以理解。内联函数使用常规的函数语法,提高了代码的可读性和维护性。
-
函数特性支持:宏函数不支持返回类型声明,无法访问局部变量,也不能递归。内联函数支持返回类型,可以访问局部变量,并且可以递归(虽然递归内联函数的效率可能不高,而且不建对递归实现内联)。
因此,C++建议使用const和enum替代宏定义的常量,使用inline声明的内联函数替代宏函数,以解决C语言中宏函数的这些缺陷。
1.2.宏函数的优势
(1)宏函数的优势主要体现在它们的灵活性、性能和通用性上。首先,由于宏没有类型检查,宏函数的参数不需要写类型,并且可以接受任意类型的参数。这种灵活性使得宏函数能够处理多种不同的数据类型,而不需要为每种类型编写不同的函数。例如,一个宏可以用来计算两个值中的最大值,无论这两个值是整数、浮点数还是其他可比较的类型。
(2)宏函数的最大优点是它们在预处理阶段进行文本替换,因此不需要创建函数栈帧。这意味着宏函数在执行时不会产生普通函数调用时的开销,如栈帧的创建和销毁,从而可能提高程序的运行效率。在性能敏感的应用中,如嵌入式系统或高性能计算,这种性能优势尤为重要。由于宏函数没有函数栈帧也导致宏函数无法调试。
1.3.对于宏函数所有错误写法、正确写法进行解析
(1)ADD宏函数的所有错误写法如下所示:
注意:由于宏函数有这么多种错误写法,进而使得宏函数容易写错,进而说明宏函数不容易掌握。
①#define ADD(int x,int y) return x + y;
- 错误原因:宏函数的参数前不能有类型声明。
- 举例说明:
int result = ADD(int 1, int 2); // 错误展开:return 1 + 2;
// 这会导致编译错误,因为return语句不能出现在这里。
②#define ADD(x,y) x + y->这个宏函数的宏体写成这样无法解决参数是个表达式
- 错误原因:没有为宏的参数添加括号,可能会导致运算优先级错误。
- 举例说明:
int a = 1, b = 2, c = 3;
int result = ADD(a, b) * c; // 错误展开:a + b * c;
// 这会导致优先级错误,实际计算为 a + (b * c),而不是预期的 (a + b) * c。
③#define ADD(x,y) (x + y)
- 错误原因:虽然这种写法通常可以得出正确结果,但在某些复杂的表达式中可能会出现问题。
④#define ADD(x,y) (x) + (y)
- 错误原因:虽然每个参数都被括号包围,但整个表达式没有被括号包围,可能会影响运算优先级。
- 举例说明:
int a = 1, b = 2, c = 3;
int result = a * ADD(b, c); // 错误展开:a * (b) + (c);
// 这会导致优先级错误,实际计算为 a * (b) + c,而不是预期的 a * (b + c)。
⑤#define ADD(x,y) ( (x) + (y) );//多写了个分号->这个宏函数的宏体写成这样无法解决宏函数参与表达式的计算
- 错误原因:在宏体的末尾添加了分号,会导致宏在参与表达式计算时出现问题。
- 举例说明:
int a = 1, b = 2;
int result = ADD(a, b) * 3; // 错误展开:( (a) + (b) ); * 3;
// 这会导致编译错误,因为分号使宏展开后的表达式不完整。
(2)ADD宏函数正确写法:
#define ADD(x,y) ( (x) + (y) )
- 解析:这种定义确保了参数被正确地括起来,整个表达式也被括起来,以保持运算的优先级,并且在宏体的末尾没有不必要的分号。
2.内联函数概念
2.1.内联函数提出的背景介绍
-
宏函数的优势:宏函数在C语言中被广泛使用,主要是因为它们在调用时不需要建立函数栈帧,这意味着宏函数的调用开销比普通函数小。这是因为宏函数通过预处理器直接将代码替换到调用点,而不是通过函数调用的方式。
-
宏函数的缺陷:尽管宏函数有性能优势,但它们存在多个缺陷,包括类型不安全、难以调试、可能导致参数多次求值等问题。这些问题使得宏函数在复杂程序中使用时可能会引入难以发现的错误。
-
内联函数的提出:C++为了保留宏函数的性能优势,同时解决宏函数的缺陷,提出了内联函数的概念,即内联函数不用建立函数栈帧而且可以调试。内联函数是C++语言特性,它允许程序员建议编译器在编译时将函数体(即函数内部所有内容)直接展开到每个调用点处,从而避免了函数调用的开销。
需要注意的是,内联函数只是对编译器的一个建议,编译器可能会根据实际情况决定是否真的将函数内联。此外,过度使用内联函数可能会导致代码膨胀,进而影响性能,因此内联函数通常用于小而频繁调用的函数。
2.2.内联函数定义
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调
用建立栈帧的开销,内联函数提升程序运行的效率。
内联函数定义的格式
inline 函数返回值类型 函数名(函数参数列表)
{
//函数体(函数定义的功能)
}
#include <iostream>
using namespace std;
//内联函数定义
inline int Add(int x, int y)
{
int z = x + y;
return z;
}
int main()
{
int ret = Add(1, 2);
cout << ret << endl;
return 0;
}
3.内敛函数特性
3.1.特性1:内联函数会使可执行程序变大
内联函数的缺陷:
-
可执行程序大小增加:使用内联函数可能会导致编译后的可执行程序大小增加。这是因为每个调用点都插入了函数体的副本,如果内联函数被多次调用,那么这些副本会累积起来,使得最终的可执行文件变大。这种大小增加并不是指运行时的内存使用量,而是指存储在磁盘上的可执行文件的大小。
-
编译时间增加:内联函数可能会增加编译时间,因为编译器需要做更多的工作来将函数体插入到每个调用点。
-
不一定总是有效:内联函数只是一个建议,编译器可能会根据具体情况决定是否真的将函数内联。如果函数体太大,编译器可能会忽略内联请求。
总结来说,inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运
行效率。(注意:这里说的以空间换取时间指的是利用内联函数会使最终的可执行程序变大)
下面解析内联函数以空间换取时间的返回最终使得可执行程序增加多少行代码:
3.2.特性2:inline不一定使函数是内联函数
inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。下图为《C++prime》第五版关于inline的建议:
解析:在C或C++中,将一个函数定义为内联函数只是向编译器提出一个建议,表明程序员希望这个函数在调用时被展开而不是通过正常的函数调用机制。然而,编译器并不保证一定会遵循这个建议。编译器会根据函数的大小、复杂度、调用频率等因素来决定是否真的将函数内联。如果函数体很大或者非常复杂,编译器可能会忽略内联请求,选择通过建立函数栈帧进行传统的函数调用。通常,只有那些行数非常小的、简单的函数才有可能被编译器内联。这是因为内联行数大的函数最终会导致可执行程序变大。
注意:平常频繁调用的小函数会加上内联(inline),递归函数一定不能定义成内联函数。
-
频繁调用的小函数:对于频繁调用的小函数,使用内联可以减少调用开销,提高程序运行效率。因此,这类函数是内联优化的良好候选。
-
递归函数不能内联:递归函数不能定义为内联函数,因为递归函数的本质是通过函数栈帧来保存每次函数调用的状态。如果递归函数被内联,它将无法正确地保存这些状态,也无法进行递归调用。此外,递归函数的内联可能会导致无限展开,从而引起代码膨胀和运行时错误。
3.3.特性3:内联函数只在头文件中直接定义实现
inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址
了,链接就会找不到。
(1)下面展示了内联函数的声明和定义分离后进而导致链接错误的例子
//Func.h
#include <iostream>
using namespace std;
inline void f(int i);
//Func.cpp
#include "Func.h"
void f(int i)
{
cout << i << endl;
}
//main.cpp
#include "Func.h"
int main()
{
f(10);
return 0;
}
//链接错误:main.obj : error LNK2019: 无法解析的外部符号 "void __cdecl
//f(int)" (?f@@YAXH@Z),该符号在函数 _main 中被引用
解析链接错误的原因:凡是出现链接错误都是在符号表中找不到这个函数。内联函数是不进符号表的,进而导致当内联函数的声明和定义分离后会导致链接错误。
具体解析:链接错误的原因在于内联函数f
的声明和定义被分离,并且其定义没有在所有调用该函数的源文件中可见。
-
内联函数的声明与定义分离:在
Func.h
头文件中,f
被声明为内联函数,这意味着编译器被建议在调用点展开这个函数的代码。然而,f
的定义位于Func.cpp
源文件中,并且在该定义中没有使用inline
关键字。即使使用了inline
关键字,由于定义和声明分离,编译器可能不会将f
视为内联函数。 -
编译器未展开内联函数:当编译器编译
main.cpp
时,它遇到了对f
的调用。由于f
的定义不在Func.h
头文件中,编译器无法在调用点展开f
的代码。编译器期望在链接阶段找到f
的完整定义,因此它生成了一个外部引用,而不是将f
的代码直接插入到调用点。 -
缺少函数的定义:在链接阶段,链接器尝试解析
main.cpp
中对f
的调用,但在编译后的目标文件中没有找到f
的定义。这是因为内联函数通常不会在符号表中创建条目。如果内联函数的定义没有在头文件中提供,或者没有被正确地包含在源文件中,那么链接器就无法找到f
的定义。 -
链接错误的具体表现:链接器报告错误
LNK2019
,表明它无法解析f
的外部符号。这是因为编译器将f
视为普通函数,但在链接时找不到其定义。由于f
的定义没有被正确地包含在编译过程中,链接器无法完成链接操作,导致程序无法生成可执行文件。
为了解决这个问题,应该将f
函数的定义包含在Func.h
头文件中,并确保使用inline
关键字。这样,编译器在编译任何调用f
的源文件时都能够直接展开f
的代码,而不会在链接阶段寻找其定义。
(2)解决方式:为了解决内联函数由于声明和定义分离导致链接错误,则C++规定内联函数只在头文件中直接定义实现
由于内联函数声明和定义分离会导致链接错误从而导致报错,所以我们就直接在头文件中定义内联函数如何实现就可以了。然后当我们要在其他源文件中使用内联函数时就直接在该源文件中包含内联函数所在的头文件即可。
①内联函数f(int i)直接在Func.h头文件中定义如何实现
②由于内联函数f(int i)在Test.cpp源文件中被调用,所以Test.cpp源文件必须包含内联函数f(int i)所在的头文件Func.h。
4.判断函数是否是内联函数的方法
4.1.内联函数查看方式
- 在release模式下,查看编译器生成的汇编代码中是否存在call Add
- 在debug模式下,需要对编译器进行设置,否则不会展开(因为debug模式下,编译器默认不会对代码进行优化,以下给出vs2013的设置方式)
4.2.从汇编角度判断函数是否是内联函数
判断方式:
在debug模式下,查看编译器生成的汇编代码中是否存在call Add,若有call指令则Add函数建立了函数栈帧同时说明Add函数不是内联函数;若没有call Add指令则Add函数是内联函数同时把Add函数内部所有内容展开在调用Add函数的位置(从汇编层面看就是Add函数把函数内部的所有汇编指令展开在调用Add函数的位置)。
案例分析:
①例1:
Add函数的行数小->由于汇编语句中没有Add函数的call指令,则Add函数的函数体对应的汇编语句展开在调用处(没有建立函数栈帧,是内联函数)
#include <iostream>
using namespace std;
inline int Add(int x, int y)
{
int z = x + y;
z = x + y;
z += x + y;
return z;
}
int main()
{
int ret = Add(1, 2);
cout << ret << endl;
return 0;
}
②例2:
Add函数的行数大->由于汇编语句中有Add函数的call指令,则Add函数的函数体对应的汇编语句没有展开在调用处(不是内联函数,建立了函数栈帧)
#include <iostream>
using namespace std;
inline int Add(int x, int y)
{
int z = x + y;
z = x + y;
z += x + y;
z = x + y;
z = x + y;
z = x * y;
z = x + y;
z += x + y;
z -= x + y;
z += x + y;
z += x * y;
z -= x / y;
z += x + y;
z += x + y;
return z;
}
int main()
{
int ret = Add(1, 2);
cout << ret << endl;
return 0;
}
auto关键字(C++11)
1.类型别名思考
随着程序越来越复杂,程序中用到的类型也越来越复杂,经常体现在:类型难于拼写、含义不明确导致容易出错。
#include <string>
#include <map>
#include<iostream>
using namespace std;
int main()
{
std::map<std::string, std::string> m
{
{ "apple", "苹果" },
{ "orange","橙子" },
{"pear","梨"}
};
std::map<std::string, std::string>::iterator it = m.begin();
while (it != m.end())
{
//....
}
return 0;
}
代码解析:std::map<std::string, std::string>::iterator 是一个类型,但是该类型太长了,特别容
易写错。但有人会想到可以通过typedef给类型取别名,比如:
#include <string>
#include <map>
#include<iostream>
using namespace std;
typedef std::map<std::string, std::string> Map;
int main()
{
Map m
{
{ "apple", "苹果" },
{ "orange", "橙子" },
{"pear","梨"}
};
Map::iterator it = m.begin();
while (it != m.end())
{
//....
}
return 0;
}
使用typedef给类型取别名确实可以简化代码,但是typedef有会遇到新的难题(typedef的缺陷):
typedef char* pstring;
int main()
{
const pstring p1; // 编译成功还是失败?
const pstring* p2; // 编译成功还是失败?
return 0;
}
修改后正确的代码:
2.auto简介
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量。
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一
个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
auto的作用是:auto是让编译器自己推导类型。
#include <iostream>
using namespace std;
int TestAuto()
{
return 10;
}
int main()
{
int a = 10;
auto b = a;//auto会自动推导a的类型,然后a的类型是什么则b的类型就是什么
auto c = 'a';
auto d = TestAuto();
//auto的实际价值是:简化代码。而且类型很长时,可以考虑自动推导类型。
cout << typeid(b).name() << endl;//typeid(变量名).name()的作用是获得变量的类型
cout << typeid(c).name() << endl;//typeid(c).name()的作用是获取c的类型
cout << typeid(d).name() << endl;
//auto e; 无法通过编译,使用auto定义变量时必须对其进行初始化
return 0;
}
注意:使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
3.auto的使用细则
3.1.auto与指针和引用结合起来使用
解析:用auto声明指针类型时,用auto和auto*没有任何区别auto*指定了右操作数必须是个指针,但是。用auto声明引用类型时则必须加&。
#include <iostream>
using namespace std;
int main()
{
int x = 10;
auto a = &x;//由于右操作数&x是个指针类型int*,则auto推导出变量a的类型是指针类型int*。
//auto* b = x;//注意:这个会编译错误。错误原因:由于auto*指定了右操作数必须是指针,但是这
里的右操作数是个整形变量,所以会发生报错。
auto* b = &x;//由于auto*指定的右操作数必须是指针,而且这里表达式的右操作数&x确实是个指
针,所以这行代码才没有报错。同时auto* 也限定死了变量b的类型是个指针。
auto& c = x;//auto&指定变量c是变量x的别名。
cout << "x的类型:" << typeid(x).name() << endl;
cout << "a的类型:" << typeid(a).name() << endl;
cout << "b的类型:" << typeid(b).name() << endl;
cout << "c的类型:" << typeid(c).name() << endl;
//变量a、b是指针,变量c是引用。
cout << "x = " << x << endl;
*a = 20;
cout << "x = " << x << endl;
*b = 30;
cout << "x = " << x << endl;
c = 40;
cout << "x = " << x << endl;
return 0;
}
3.2.在同一行定义多个变量
解析:当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译
器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
#include <iostream>
using namespace std;
void TestAuto()
{
auto a = 1, b = 2;
//auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
4.auto不能推导的场景
4.1.auto不能作为函数的参数
#include <iostream>
using namespace std;
//此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导
void TestAuto(auto a)
{}
4..1.1.解析auto不能作为函数的参数的过程:
(1)函数调用的时期
函数调用是在程序运行时进行的。以下是函数调用的几个关键时期和阶段:
-
编译时:在编译阶段,编译器会检查函数的声明和调用是否一致,包括参数的数量和类型。但是,编译器不会在编译时执行函数调用。
-
链接时:如果函数是在不同的源文件中定义的,链接器会确保函数的定义与调用点正确连接。链接器不会执行函数调用,它只是确保所有的引用都能找到对应的定义。
-
运行时:函数调用实际上发生在程序运行时。当程序执行到函数调用语句时,程序的控制流会转移到被调用函数的代码,执行函数体中的指令。
(2)auto
不能用作函数参数的类型,原因如下:
注意:在C++中,auto
关键字用于自动类型推导,它允许编译器在编译时自动确定变量的类型。
①类型推导时机:auto
类型的变量在声明时必须能够确定其类型。这意味着编译器需要在编译时就能知道auto
变量的确切类型。
②函数参数的类型:函数参数的类型在函数调用时才能确定。当函数被声明时,编译器并不知道将会传递给函数的参数的具体类型。因此,编译器无法在函数声明时对参数类型进行推导。
4.2.auto不能直接用来声明数组
#include <iostream>
using namespace std;
void TestAuto()
{
int a[] = { 1,2,3 };
//auto b[] = { 4,5,6 };//这里会编译错误
}
4.3.为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法。
(1)自动存储期指的是一种变量存储期限的属性,它关联到程序中的变量何时被创建和销毁。具体来说,具有自动存储期的变量具有以下特点:
-
生命周期:变量的生命周期是从它被声明时开始,直到包含它的最内层代码块(通常是函数体或代码块
{...}
)结束时结束。 -
存储位置:自动存储期的变量通常存储在内存的栈区上,这个区域用于存放函数调用时创建的局部变量。
-
自动管理:这些变量的分配和回收是自动进行的,不需要程序员显式地分配或释放内存。
以下是一些关于自动存储期的具体说明:
-
局部变量:在函数内部或代码块内部声明的变量通常具有自动存储期。当函数被调用时,这些变量被创建;当函数返回时,这些变量被销毁。
-
函数参数:传递给函数的参数也具有自动存储期,尽管它们的生命周期仅限于函数调用的执行期间。
-
临时对象:在某些情况下,例如在表达式求值过程中创建的临时对象,也具有自动存储期。
(2)在 C++98 标准中,auto
关键字用于声明具有自动存储期的变量,即局部变量默认的存储类别。然而,这种用法并不常见,因为局部变量默认就是自动存储期的,所以很少需要显式地使用 auto
来声明变量。
(3)C++11 引入了新的 auto
的用法,它允许自动类型推导,这样编译器可以根据初始化表达式的类型自动推断出变量的类型。这个特性大大简化了变量的声明,尤其是在处理复杂类型或模板时。
为了避免与 C++98 中 auto
的旧有用法发生混淆,C++11 标准做出了一些调整:
-
废弃了
auto
作为存储类别指示符的用法:在 C++11 及以后的版本中,auto
不再用于声明具有自动存储期的变量。这意味着在 C++11 中,你不能使用auto
来指示变量的存储类别。 -
保留了
auto
作为类型指示符的用法:C++11 只保留了auto
作为类型指示符的用法,用于自动类型推导。现在,当你使用auto
时,你是在告诉编译器根据初始化表达式的类型自动推导出变量的类型。
以下是一个示例,展示了 C++11 中 auto
的新用法:
int x = 42;
auto y = x; // y 的类型被自动推导为 int
在这个例子中,y
的类型是根据 x
的类型自动推导出来的,因此 y
也是一个 int
类型的变量。
总结来说,为了避免与 C++98 中 auto
的旧用法混淆,C++11 对 auto
的功能进行了重新定义,现在 auto
主要用于类型推导,而不是用于指示变量的存储类别。
4.4.auto在实际中最常见的优势用法就是跟的C++11提供的新式for循环,还有lambda表达式等进行配合使用。
基于范围的for循环(C++11)
1.范围for的语法
1.1.遍历数组方式1
在C++98中如果要遍历一个数组,可以按照以下方式进行:
#include <iostream>
using namespace std;
//遍历数组方式1
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
//利用指针偏移来访问数组元素
for (int* p = array; p < array + sizeof(array) / sizeof(array[0]); ++p)
cout << *p << " ";
//换行
cout << endl;
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
{
array[i] *= 2;
cout << array[i] << " " ;
}
//换行
cout << endl;
}
int main()
{
TestFor();
return 0;
}
测试:
1.2.遍历数组方式2
对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号“ :”分为两部分:第一部分是范
围内用于迭代的变量,第二部分则表示被迭代的范围。
#include <iostream>
using namespace std;
//遍历数组方式2
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
//案例:1:
//解析for (auto e : array)的作用是:自动依次取数组arry中数据赋值给e对象(即变量),并自动判断结束(即自动判断数组arry什么时候遍历结束)。
//注意:for(auto e : array)中的e只是个对象(或者说变量),但是这个对象(即变量)的名字不一定 是e,也可以是x或者其他名字。
for (auto e : array)
cout << e << " ";
cout << endl;
//auto所在的位置只是声明数据类型,所以auto位置的类型可以是int或者其他类型。
for (auto a : array)
{
a *= 2;//注意:若a是个引用的话则可以通过对a进行赋值操作改变数组array中元素的值,但是a不是引用,所以这里的a *= 2赋值操作无法改变数组array中元素的值,这里只是把a的值打印出来而已。
cout << a << " ";
}
cout << endl;
//这里可以验证上一个for (auto a : array)循环是否改变数组array元素的内容。
for (int b : array)
cout << b << " ";
cout << endl;
//案例2:
//解析for (auto& e : array)的作用:
for (auto& e : array)//auto& e指定变量e是个引用(别名)。
{
e *= 2;//由于变量e是个引用,所以这里对变量e进行赋值操作就是对引用对应的数组array中的元素进行赋值操作。由于e是个引用才会使得e *= 2赋值操作改变数组array元素中的值。
cout << e << " ";
}
cout << endl;
//这里可以验证上一个for (auto& e : array)循环是否改变数组array元素的内容
for (auto c : array)
cout << c << " ";
cout << endl;
}
测试:
注意:与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环。
1.3.总结
(1)遍历数组方式1:for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
这种方式使用一个索引变量i
来直接通过数组下标访问元素。它通过指针算术(即数组下标)来遍历数组。
(2)遍历数组方式2:范围for循环
①解析for (auto a : array)的作用:
for (auto a : array)
是C++11中引入的范围for循环(range-based for loop)的语法。它的作用是遍历数组array
中的每个元素,并将每个元素的值依次赋给变量a
。以下是详细的解析:
auto
:告诉编译器自动推导变量a
的类型。在这个例子中,编译器会推导出a
的类型为int
,因为array
是一个int
类型的数组。a
:是循环变量,它在每次迭代中都会被赋予数组array
中下一个元素的值。: array
:指定要遍历的范围,即数组array
。
在这个循环中,变量a
是数组元素的副本,因此对a
的任何修改都不会影响原始数组array
中的元素。循环会一直进行,直到数组中的所有元素都被遍历。
②解析for (auto& a : array)的作用:
for (auto& a : array)
也是范围for循环的一种形式,但它通过引用来遍历数组中的元素。以下是详细的解析:
auto&
:告诉编译器自动推导变量a
的类型,并且a
是一个引用。在这个例子中,编译器会推导出a
是int
类型的引用。a
:是循环变量,它作为数组元素的引用,而不是副本。
由于a
是引用,所以对a
的任何修改都会直接反映到原始数组array
中的相应元素上。这意味着在循环体内部对a
的赋值操作会改变数组array
中的元素。
(3)注意:
- 范围for循环确实可能在底层实现上转换为类似方式1的传统for循环,使用指针或迭代器来遍历元素。
- 当使用范围for循环遍历数组时,编译器生成的代码通常会与手动编写的传统for循环在性能上相当,因为它们都涉及到指针算术操作。
- 范围for循环的优点在于它提供了更简洁的语法,并且可以自动处理迭代器的开始和结束,减少了编码错误的可能性。
总之,范围for循环是C++提供的一种语法糖,它使得代码更加简洁和易读,但在底层,它仍然依赖于传统的遍历机制,如指针或迭代器。方式1和方式2遍历数组的速度是一样的。
2.范围for的使用条件
2.1.for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供
begin和end的方法,begin和end就是for循环迭代的范围。
注意:以下代码就有问题,因为for的范围不确定.
#include <iostream>
using namespace std;
void TestFor(int array[])
{
//由于array是个指针而且array又不是数组,所以这个范围for会发生报错。
for (auto& e : array)//这行发生编译错误
cout << e << endl;
}
(1)代码解析:
在C++中,范围for循环要求迭代的范围必须是确定的。对于数组而言,这意味着编译器需要知道数组的开始和结束位置。然而,当数组作为函数参数传递时,它实际上被退化成了一个指向其第一个元素的指针。这意味着在函数内部,编译器无法确定原始数组的大小。在这个例子中,TestFor
函数接受一个整型数组的参数。但是,在函数内部,array
仅仅是一个指向整型的指针,而不是实际的数组。因此,编译器无法确定 array
的大小,也就无法确定迭代的范围。
(2)代码错误原因总结:
①数组退化成指针:当数组作为函数参数传递时,它会被退化成一个指向其第一个元素的指针。因此,在函数内部,我们失去了数组的大小信息。
②范围不确定:由于 array
在函数内部只是一个指针,编译器不知道这个指针指向的内存中有多少个元素,因此无法确定范围for循环应该迭代多少次。
③编译错误:由于上述原因,编译器无法为范围for循环生成正确的迭代逻辑,因此会报编译错误。
(3)解决方式:
为了解决这个问题,你可以通过以下方式之一来确保迭代的范围是确定的:
方式1:传递数组的额外大小信息到函数中。
void TestFor(int* array, size_t size)
{
for (size_t i = 0; i < size; ++i)
cout << array[i] << endl;
}
方式2:使用标准库容器(如 std::vector
或 std::array
),它们提供了 begin
和 end
方法来确定迭代的范围。
#include <array>
void TestFor(std::array<int, N>& arr)
{
for (auto& e : arr)
cout << e << endl;
}
//解析:在这里,N 是一个常量表达式,它代表了数组的大小,而 std::array 维护了数组的大小信息,因此可以安全地用于范围for循环。
指针空值---nullptr(C++11)
1.C++98中的空指针NULL
在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现
不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们基本都是按照如下
方式对其进行初始化:
void TestPtr()
{
int* p1 = NULL;
int* p2 = 0;
// ……
}
NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何
种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:
#include <iostream>
using namespace std;
void f(int)
{
cout << "f(int)" << endl;
}
void f(int*)
{
cout << "f(int*)" << endl;
}
int main()
{
f(0);
f(NULL);
f((int*)NULL);
return 0;
}
程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的
初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器
默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void
*)0。
为了解决NULL在C++中的歧义,则C++提出利用nullptr来代替C语言中的空指针NULL:
2.C++11中的空指针nullptr
在 C++ 中,NULL
和 nullptr
都是用来表示空指针的,但它们之间存在一些重要的区别,特别是在 C++11 标准引入 nullptr
之后。
(1)NULL
在 C++98 和之前的版本中,NULL
是一个预处理宏,它被定义为整数 0。在大多数情况下,NULL
被用来表示空指针。
当 NULL 被用作函数参数时,如果存在多个函数调用版本的函数,编译器需要决定调用哪一个。由于 NULL 在 C 和 C++ 中通常被定义为 0 或者 (void*)0
,它既可以表示一个空指针,也可以被解释为一个整数值。这就可能导致以下问题:
void func(int i);
void func(char* ptr);
func(NULL); // 这里有歧义:是调用 func(int) 还是 func(char*)?
代码解析:在上面的例子中,func(NULL);
的调用是模糊的,因为 NULL 既可以被解释为 int
类型的 0,也可以被解释为 char*
类型的空指针。在 C++98 及之前的版本中,这通常会导致调用 func(int)
,因为 NULL 被定义为 0,而 0 是一个整数值。
(2)nullptr
为了解决 NULL
的问题,C++11 引入了 nullptr
,它是一个明确表示空指针的值,nullptr的类型为 std::nullptr_t
,它不会与任何整数值类型兼容。因此,使用 nullptr
可以消除NULL的这种歧义:
func(nullptr); // 明确调用 func(char*),因为 nullptr 是一个空指针类型
3.注意事项
3.1.在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入
的。
3.2.在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
3.3.为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。
标签:const,入门,int,C++,语法,引用,指针,变量,函数 From: https://blog.csdn.net/2302_76314368/article/details/142857332