C++入门
简要介绍从C语言过渡到C++所需了解的基础语法知识
前言
C++ 在 C 语言的基础上引入了类和对象、继承、多态、封装等面向对象的特性。它既支持过程式编程(与 C 语言类似),也支持面向对象编程。C++ 继承了 C 语言的核心特性,并扩展了许多功能。
C++在许多领域都有广泛的应用,包括游戏开发、系统/应用软件、驱动和嵌入式系统开发、高性能服务器和客户端应用,以及创建大型复杂的图形用户界面和数据库应用。
我将在博客记录学习到的c++知识,提供大家参考,也方便自己复习。
一、C++关键字
c++目前有63个关键字,是c语言近两倍(c语言有32个关键字)。
这里只作简单了解
二、命名空间
当项目规模越来越大时,容易出现相同的类,函数重名等情况,这会使编译器无法判断所使用的是哪一个 类或者是函数,出现报错现象。因此引入命名空间这个概念用于解决上述问题。
这里给出一个典型命名冲突样例
#include<stdio.h>
int rand=11;
int main()
{
printf("%d\n", rand);
return 0;
}
这段代码能正常打印全局变量rand
但是当添加<stdlib.h>
头文件时出现报错情况
#include<stdio.h>
#include<stdlib.h>
int rand=11;
int main()
{
printf("%d\n", rand);
return 0;
}
rand重定义,这是因为stdlib.h头文件已经包含了名为rand的函数,它与开发者定义的变量名冲突。这就是命名冲突
现象。
🍐 作用
命名空间通过给予附加信息来区分不同库中类或函数,允许开发者将相关的类、函数、变量等封装在一个逻辑上的命名空间中,从而避免在大型项目中不同部分之间的名称冲突。
到这里我们就能解决上述的问题——使用命名空间区分两个rand
使编译器能识别两个rand
#include<stdio.h>
#include<stdlib.h>
namespace test
{
int rand=11;
}
int main()
{
//全局中的rand是stdlib.h中的rand函数,因此rand是函数指针
printf("%p\n", rand);
return 0;
}
此时能正常打印出全局rand函数的地址
🍉 定义
命名空间使用namespace
关键字来定义
namespace Name
{
int val;
int Add(int a,int b)
{
return a+b;
}
class MyClass {};
}
命名空间内可以包含几乎所有C++中的实体
:函数、类、变量、各种类型,也可以嵌套命名空间。
- 命名空间的嵌套
namespace test3
{
int val=1;
namespace test4
{
int val = 2;
int Add(int a,int b)
{
return a + b;
}
}
}
命名空间嵌套过深:过深的嵌套会降低代码的可读性和可维护性。
注意 :命名空间只能在全局定义!
- 匿名命名空间
匿名命名空间没有名字,通常用于将某些元素限制在当前文件内,防止外部链接。
namespace {
int internalval;
void internalFunction();
}
在当前文件中,internalval 和 internalFunction 只能被该文件访问。
- 命名空间合并
可以在不同的地方定义相同名字的命名空间,这些定义会被合并为一个命名空间。这样可以将相关的定义分散在不同的文件中
// File1.cpp
namespace MyNamespace {
void function1();
}
// File2.cpp
namespace MyNamespace {
void function2();
}
🍓 访问
命名空间的访问一般有两种方式
(1)作用域限定符
作用域限定符也称域解析操作符 (两个冒号::)。使用命名空间名称加作用域限定符
来访问命名空间内的元素。
#include<stdio.h>
#include<stdlib.h>
namespace test
{
int rand=11;
}
int main()
{
//全局中的rand是stdlib.h中的rand函数,因此rand是函数指针
printf("%p\n", rand);
//使用作用域限定符访问命名空间内部元素
printf("%d\n", bit::rand);
return 0;
}
测试结果
(2)命名空间展开
- using 指令
命名空间部分展开——引入命名空间的某些部分到当前作用域
#include<stdio.h>
namespace bit
{
int val = 1;
}
//using bit::val;
int main()
{
using bit::val;
printf("%d\n", val);
return 0;
}
测试结果
- using namespace 指令
命名空间完全展开——引入整个命名空间到当前作用域
#include<stdio.h>
namespace bit
{
int val = 1;
}
//using namespace bit;
int main()
{
using namespace bit;
printf("%d\n", val);
return 0;
}
测试结果
编译默认查找优先级
- 先在当前局部域找
- 再到全局域找
- 最后到展开的命名空间中查找
🍎 注意事项
-
尽量避免在全局作用域或头文件中使用using namespace指令,因为这可能会引入意外的命名冲突。
-
使用完全限定名是一种清晰且避免命名冲突的好方法,尽管它可能会使代码更加冗长。
-
同一个域不可以定义重名,不同域可以定义重名(指函数名、变量名等等)
-
如果多个命名空间中存在相同名称的元素,需要小心使用(防止名称冲突)
三、C++输入输出
🏀概念
C++ 的 I/O 发生在流中,流是字节序列
如果字节流是从设备(如键盘、磁盘驱动器、网络连接等)流向内存,这叫做输入操作
如果字节流是从内存流向设备(如显示屏、打印机、磁盘驱动器、网络连接等),这叫做输出操作
cout --> console 控制台 + out cout 表示标准输出,它输出到控制台。
cin --> console 控制台 + in cin 表示标准输入,它从键盘接收输入。
⚽️I/O库头文件
常用i/o库头文件如下
头文件 | 解析 |
---|---|
iostream | 定义了 cin、cout、cerr 和 clog 对象,分别对应于标准输入流、标准输出流、非缓冲标准错误流和缓冲标准错误流,支持基本的格式化输出 |
iomanip | 允许开发者控制输出格式,如设置小数点后的位数、设置宽度、对齐方式,控制 C++ 标准库中的输入/输出流的格式 |
fstream | 提供了一种方便的方式来读写文件,为用户控制的文件处理声明服务 |
🌿iostream
- 功能介绍
是 C++ 标准库中的一个非常核心的库,它提供了输入和输出的功能。这个库包含了一系列的类和对象,用于从标准输入(通常是键盘)读取数据,以及向标准输出(通常是屏幕)写入数据。
(1)cin:用于从标准输入设备(通常是键盘)读取数据。它是istream类的对象,支持通过重载的右移操作符(>>)从输入流中提取数据。
(2)cout:用于向标准输出设备(通常是屏幕)输出数据。它是ostream类的对象,支持通过重载的左移操作符(<<)向输出流中插入数据。
(3)cerr:用于向标准错误设备(通常是屏幕)输出错误流。与cout不同,cerr通常用于输出错误信息,且其输出不会被缓冲,可以立即显示在屏幕上。
(4)clog:与cerr类似,也用于输出错误信息,但clog的输出会被缓冲。这意呀着,除非缓冲区满或显式地刷新缓冲区,否则clog的输出可能不会立即显示在屏幕上。
- cin/cout 基本用法
(1)包头文件
#include<iostream>
(2) 展开cin和cout
展开std方法
//std是标准命名空间(namespace)的标识符。C++标准库中的所有内容都被定义在这个命名空间中
using namespace std;
//小型项目或算法竞赛推荐使用,减少代码冗杂,使代码清晰整洁。大型项目不推荐使用,容易命名冲突
部分展开cin和cout
//适合在大型项目中使用
using std::cin;
using std::cout;
(3)使用输入输出流
cin输入操作,cout输出操作。
<<是流插入运算符,>>是流提取运算符。
int x;
//已经展开
cin>>x;
cout<<x;
//没有展开
std::cin>>x;
std::cout<<x;
关于c++输入输出更加详细的讲解—— C++的iostream标准库介绍+使用详解
☘️iomanip
- 功能介绍及使用(仅介绍部分常用函数)
它提供了一组用于格式化输入和输出的操纵符(manipulators)。这些操纵符帮助你控制数据的显示格式,使其更加易读和符合要求。
(1)setw:设置字段宽度
设置要在输出操作中使用的字段宽度。
输出机制
- 占位数大于数字串个数(包括小数点等等符号),在数字串前用空格补齐占位数。
#include<iostream>
#include<iomanip>
int main()
{
//设置5占位10格
std::cout << std::setw(10)<< 5 << std::endl;
//设置10占位10格
std::cout << 5 << std::setw(10) << 10 << std::endl;
//设置8占位格,7占位2格
std::cout << 5 << std::setw(5) << 8 << std::setw(2)<<7<< std::endl;
return 0;
}
注意设置的是跟在setw后的数占位的个数
- 占位数小于等于数字串个数(包括小数点等等符号),直接输出未改变的数字串。
#include<iostream>
#include<iomanip>
int main()
{
//占位数小于等于数字串
std::cout << std::setw(3) << 47643 << std::endl;
std::cout << std::setw(4) << 9.88644 <<std::endl;
return 0;
}
(2)setprecision:设置小数精度 && fixed:定点格式输出
设置用于在输出运算中设置浮点值格式的十进制精度。
#include<iostream>
#include<iomanip>
int main()
{
double f = 0.1565926;
//使用默认科学记数比原字符长不会补零!
std::cout << std::setprecision(10) << f << std::endl;
//保留5位小数
std::cout << std::setprecision(5) << f << std::endl;
//保留3位小数
std::cout << std::setprecision(3) << f << std::endl;
//保留一位小数
std::cout << std::setprecision(1) << f << std::endl;
//保留0位小数,输出浮点数的最接近的整数的值
std::cout << std::setprecision(0) << f << std::endl;
//设置为负数直接输出
std::cout << std::setprecision(-1) << f << std::endl;
return 0;
}
测试结果
得到测试结果后,可以发现并不符合我们的预期,问题如下:
(1)保留的10位小数没有补0
(2)保留的3位、1位、0位小数均多了1
那么导致这样的结果的原因是什么呢,如何解决呢?
我们先来了解一下浮点的默认输出格式。这里引入一个概念——默认浮点数记数法
- 默认浮点数记数法最多保留6位(数的个数为小数点前后的位数和,第一位的0和数字串最后连续的0不占位)
#include<iostream>
#include<iomanip>
int main()
{
double f = 123.451;
double f1 = 123.0561111;
double f2 = 10.45611;
double f4 = 0.1234561;
double f3 = 10.4500;
//数的个数为小数点前后的位数和,默认保留6位
std::cout << f << std::endl;
std::cout << f1 << std::endl;
std::cout << f2 << std::endl;
//第一位的0和数字串最后连续的0不占位
std::cout << f3 << std::endl;
std::cout << f4 << std::endl;
return 0;
}
测试结果
- 默认浮点数记数法规定最后一位的下一位会进行四舍五入
#include<iostream>
#include<iomanip>
int main()
{
double f1 = 0.1234561;
double f2 = 0.1234567;
double f3 = 123.4561;
double f4 = 123.4567;
double f5 = 1.234101;
double f6 = 1.234909;
std::cout <<"舍去:"<< f1 << " 进位:" << f2 << std::endl;
std::cout << "舍去:" << f3 << " 进位:" << f4 << std::endl;
std::cout << "舍去:" << f5 << " 进位:" << f6 << std::endl;
return 0;
}
测试结果
到这里我们已经得到了出现问题的原因——默认浮点数记数法
那么要如何解决这个问题呢?
这里引入std::fixed函数
std::fixed可以修改默认浮点数记数个数,被称为定点格式输出或非科学记数输出
#include<iostream>
#include<iomanip>
int main()
{
double f1 = 1.9989657481;
double f2 = 1.9989574889;
double f3 = 1.0000001991;
double f4 = 1.0000001999;
std::cout << std::fixed << std::setprecision(9) << f1 << " " << f2 << std::endl;
std::cout << f3 << " " << f4 << std::endl;
return 0;
}
测试结果
总结:当需要设置小数精度时,应该根据需要使用std::fixed来搭配std::setprecision使用
(3)setiosflags:设置格式标志 && ios:控制流的状态和行为
std::ios是 C++ 标准库中的一个基类,用于表示流(stream)的状态和行为。它定义了一系列与流操作相关的常量和成员函数,这些常量和函数用于控制流的打开模式、格式化输出、错误处理等。
- 功能介绍
(1)文件操作模式:std::ios 包含了一系列用于控制文件打开模式的常量
(2)格式化输出:std::ios提供了一系列用于控制输出格式的成员函数和常量,如设置浮点数精度(std::ios::precision)、设置基数(std::ios::hex、std::ios::dec、std::ios::oct)等。
(3)错误处理:std::ios提供了错误处理机制,通过检查流的状态(如std::ios::failbit、std::ios::badbit、std::ios::eofbit)来判断操作是否成功,并据此进行相应的错误处理。
std::setiosflags 是 C++ 中用于设置输出流(如 std::cout)格式标志的一个函数。这个函数通过修改流对象的格式控制状态,来影响后续的输出格式。它接受一个或多个格式标志作为参数,这些标志由 ios 类中的枚举值指定,用于控制诸如数值的显示方式、对齐方式、填充字符等输出格式。
主要使用std::ios的格式化输出函数结合std::setiosflags完成设置格式标志
(1)填充标志输出
(2)格式化标志输出
(3)对齐标志输出
用法举例
以 std::ios::hex:设置流位十六进制输出 举例
#include <iostream>
#include <iomanip>
int main() {
int num = 255;
std::cout << std::setiosflags(std::ios::hex) << num << std::endl;
// 输出: ff
return 0;
}
需要深入了解的可以阅读这篇博文—— iomanip详解
🍀fstream
fstream 是 C++ 标准库中的一个类,用于文件的输入输出操作。它结合了 ifstream(输入文件流)和 ofstream(输出文件流)的功能,允许同时进行文件的读取和写入。
这里仅作简单介绍,后续补充这方面的资料
🏓问题与反思
- cout为什么能自动识别类型,如何识别?
cout是一个ostream类型的对象,ostream其实是模板类basic_ostream,其内重载了针对各种各样的type的operator<<,然后通过返回自身,使得可以连续cout,例如cout << 1 << 2,cout << 1的返回类型仍然是*this,也就是cout,接下来便又会cout << 2。即当使用 std::cout << value; 时,编译器会根据 value 的类型选择正确的 << 运算符重载版本。 —— 详细解释文章
- endl和\n作用都是换行,它们有什么区别吗,什么情况用哪个?
endl和\n都用于在输出流中插入换行符,但是不意味完全相同
主要区别:
- 换行和刷新流
(1)std::endl:除了插入换行符外,还会刷新输出流。
这意味着它会强制将缓冲区中的内容立即写入到实际的输出设备(如屏幕)。这种行为在需要确保输出立刻可见时非常有用。
(2)\n:仅插入换行符,不会自动刷新输出流。
这意味着换行符会被添加到缓冲区,但输出不会立即显示,直到缓冲区被刷新(例如,程序结束或缓冲区满)。
由于上述的区别,导致std::endl和\n在性能上有区别- 性能分析
由于 std::endl 触发了流的刷新,频繁使用它可能会导致性能问题,特别是在大量输出时。流的刷新操作相对较昂贵,因为它涉及到与输出设备的交互。相比之下,使用 \n 不会引发流刷新,因此如果你不需要立即看到输出,‘\n’ 会更高效。
因此当无需刷新缓存区时,使用\n比endl会更高效
- c++的输入输出和c语言的输入输出区别,什么情况用哪个?
- 主要区别
(1)灵活性、安全性
c语言使用scanf和printf,需要开发者手动输入类型。c++使用cin和cout,可以使用<< 运算符重载自动识别类型。因此c语言的输入输出在灵活性上比不上c++输入输出。在安全性问题上,由于c语言输入输出需要手动输入类型,容易造成类型不匹配,运行时可能会出现问题,因此c语言输入输出的安全性也比c++输入输出弱。
(2)性能分析
在某些情况下,C语言的scanf和printf可能会比C++的cin和cout更快,因为scanf和printf是直接与C语言的底层I/O系统交互的,而cin和cout则经过了C++的I/O流库的封装。然而,这种性能差异在现代编译器和硬件上通常可以忽略不计。
四、缺省参数
🚕 概念
缺省参数允许我们在声明或定义函数时为函数的参数指定一个默认值,在调用时没有传入实参,就使用这个默认值。
🚗 使用规则
- 半缺省参数必须从右向左连续
缺省参数不能随便给,需要自右向左连续的给,要么从右向左只给1个或2个,要么全部都给,必须是连续的,否则不符合规定
实参的使用和缺省参数方法相同,方向是自左向右
- C语言编译器不支持使用
C语言不支持缺省函数,这意味着在C语言中,如果函数需要默认值,通常需要通过其他方式实现,如使用宏定义或重载多个函数。相比之下,C++的缺省函数提供了更加直接和方便的解决方案。
- 缺省值必须是常量或全局变量
现代C++标准(特别是C++11及以后)更倾向于使用常量表达式(特别是constexpr变量)作为缺省值,而不是全局变量。全局变量作为缺省值在实践中并不常见,因为它们可能导致代码难以理解和维护,以及潜在的运行时问题(如全局状态的变化)。
🚙 分类
- 全缺省参数
函数的每个参数都有默认值。
实参的使用规则
实参的传递与缺省参数使用规则类似,方向自左向右
使用举例
- 半缺省参数
函数的部分参数有默认值,但必须从右往左依次给出。这是因为如果允许间隔给出默认值,那么在调用函数时,编译器将无法确定哪些参数使用了默认值,哪些参数被省略了。
实参的使用规则
(1)实参的传递与缺省参数使用规则类似,方向自左向右
(2)注意无缺省参数的位置必须传入实参!
使用举例
#include<iostream>
using namespace std;
void test(int a , int b , int c = 9)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout<< "c = " << c <<"\n"<<endl;
}
int main()
{
//正确使用
test(1, 2, 3);
test(2, 5);
//错误使用
//test(1, , 9)
return 0;
}
测试结果
五、函数重载
📪 概念
函数重载是一种特殊情况,C++允许在同一作用域中声明几个类似的同名函数,这些同名函数的形参列表(参数个数,类型,顺序)必须不同,常用来处理实现功能类似数据类型不同的问题。
📫 使用规则
- 函数名称必须相同
- 参数列表必须不同(参数类型,参数个数,参数排列顺序)
防止调用歧义
//参数类型名不同
void test(int a, int b)
{
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b <<"\n"<< std::endl;
}
void test(double a, double b)
{
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b << "\n" << std::endl;
}
//参数个数不同
void test(int a, int b, int c)
{
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b << std::endl;
std::cout << "b = " << b << "\n" << std::endl;
}
void test(int a)
{
std::cout << "a = " << a << std::endl;
}
//参数顺序不同
void test(int a, double b)
{
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b << "\n" << std::endl;
}
void test(double a, int b)
{
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b << "\n" << std::endl;
}
- 函数返回类型可以不同,但是参数必须有所不同
(仅仅返回类型不同不足以成为函数的重载)
void test(int a, int b)
{
std::cout << "a = " << a << std::endl;
std::cout << "b = " << b <<"\n"<< std::endl;
}
int test(int a)
{
std::cout << "a = " << a << std::endl;
}
易混淆知识点
让我们观察这段代码,两个不同命名空间,同样的test函数,当我们释放命名空间,它们是否构成函数重载呢?
#include<iostream>
namespace np1
{
void test(int a,double b)
{
std::cout << a << " " << b << "\n" << std::endl;
}
}
namespace np2
{
void test(int a, int b)
{
std::cout << a << " " << b <<"\n"<< std::endl;
}
}
int main()
{
//展开命名空间
using::np1::test;
using::np2::test;
//展开两个不同命名空间的具有相同函数名的函数是否能构成重载?
//不能但是它们两都能正常运行
test(3,9.9);
test(3, 4);
return 0;
}
不能 ,为什么–>因为不在同一个作用域。
在不同的作用域中,就算函数一模一样也不构成函数重载!
📭 C++支持函数重载的原理
C++支持函数重载的原理是——名字修饰
名字修饰是一种在编译过程中,将函数、变量的名称重新改编的机制。简单来说就是编译器为了区分多个同名函数,规定了一个新的规则来对原本的名字进行修饰
那为什么C语言不支持函数重载呢,是因为C语言的名字修饰过于简单,无法区分参数不同的重名函数
让我们查看一下C语言的名字修饰(可以在.c文件查看,也可以在.cpp查看(函数名前加上 extern "c"
即可))
明显C语言中并没有函数名修饰,无法根据参数的不同来区分
让我们查看一下C++的名字修饰
可以清晰的看到
void test(int a,double b);
和void test(double a,int b);
编译后的地址不同,因此编译器可以区分这两个重名函数。
这里报错原因
函数的地址其实是函数内第一句指令的地址,如果函数只有声明,那么函数就不会进入符号表,当调用这个函数时通过函数名进入符号表就找不到地址,所以报错
不同的编译器有自己的函数名修饰规则,上面的就是在Windows下vs的修饰规则,有兴趣的同学可以自行深入学习。
六、引用
🐶 概念
引用不是新定义一个变量,而是给已经存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用一块内存空间。
C++的引用的符号复用了C语言的取地址符号&,同时引用变量也可以被引用。
#include<iostream>
using namespace std;
int main()
{
int a = 7;
//b引用a,此时b是a的别名,共享一块内存空间
int& b = a;
//b作为a的引用变量,也可以作为c的引用对象
int& c = b;
cout << a << endl;
//out:7
c++;
cout << b << endl;
//out:8
cout << c << endl;
//out:8
//a b c的地址信息相同
return 0;
}
那么引用和被引用的变量类型是否需要一致呢,让我们观察下面代码
很明显,引用变量和被引用的对象类型必须一致
被引用的对象也可以是指针
#include<iostream>
using namespace std;
int main()
{
int x = 1;
int* a = &x;
int*& b = a;
return 0;
}
🐹 引用特性
-
引用在定义时必须初始化
-
一个变量可以有多个引用
-
引用一旦引用一个实体,再不能引用其他实体
🐼 常引用
C++中的常引用是一种特殊的引用类型,它允许你通过一个别名访问一个对象,但这个别名不允许修改所引用的对象的内容。
(1)常引用的声明
通过const
关键字声明,例如b是a的常引用,无法通过修改b来改变a的值
(2)注意事项
如果引用对象被const修饰,引用变量也要用const修饰,否则会报错
这里引入一个知识点—— 权限的放大(访问权限的提升或扩展)
权限的放大在 类和对象 中使用频繁,但在常引用中,这是不可取的
以上述代码为例,引用对象a被const修饰,权限是可读不可写,引用变量c没有被const修饰,权限是可读可写,出现了权限的放大。
权限的平移
引用对象a和引用变量b权限均为可读不可写
#include<iostream>
using namespace std;
int main()
{
//
const int a = 10;
const int& b = a;
return 0;
}
权限的缩小
引用对象a的权限为可读可写,引用变量b的权限为可读不可写
#include<iostream>
using namespace std;
int main()
{
int a = 10;
const int& b = a;
//即只能通过b访问a和b共用的空间,不能通过修改b来修改a和b共用空间内的值
return 0;
}
注意:const 在 *
之前修饰的是内容,在 *
之后修饰的才是本身
总结:在常引用中,权限只能缩小或平移,不能放大
🐰 使用场景
- 作参数
之前我们使用Swap函数是这样写的:通过C语言的取地址符号传入ab的地址,再解引用,实现地址交换
void Swap(int *a,int *b)
{
int tmp=*a;
*a=*b;
*b=tmp;
}
int main()
{
int x=8;
int y=4;
Swap(&x,&y);
}
现在我们可以使用C++的引用完成交换功能,使用引用后,形参是实参的别名。
#include<iostream>
using namespace std;
void Swap(int& a, int& b)
{
int tmp = a;
a = b;
b = tmp;
}
int main()
{
int x = 1;
int y = 2;
cout << x << " " << y << endl;
//out:1 2
Swap(x, y);
cout << x <<" "<< y << endl;
//out:2 1
return 0;
}
在日常使用的数据结构教科书中,我们能经常看到它的身影
在单链表中使用了二级指针SLNode** pphead
void SLPushBack(SLNode** pphead, SLDateType x){
assert(pphead);
SLNode* newnode=SLBuyNode(x);//调用申请节点函数
//链表为空,新节点作为head
if (*pphead == NULL) {//解引用一次,即为第一个节点的地址
*pphead = newnode;
newnode->next = NULL; //新节点指向NULL
return;
}
//链表不为空,找到尾节点
SLNode* TailList = *pphead;//解引用一次,即为第一个节点的地址
while (TailList->next)//当下一个节点不为NULL时继续循环
{
TailList = TailList->next;
}
//结束while后TailList即为尾节点,使尾节点指向新节点(更新尾节点)
TailList->next = newnode;
newnode->next = NULL;
}
学习了引用后,我们可以用它替代二级指针SLNode*& pphead
,作为实参的引用,增强代码安全性和可读性
void SLPushBack(SLNode*& pphead, SLDateType x){
assert(pphead);
SLNode* newnode=SLBuyNode(x);//调用申请节点函数
//链表为空,新节点作为head
if (*pphead == NULL) {//解引用一次,即为第一个节点的地址
*pphead = newnode;
newnode->next = NULL; //新节点指向NULL
return;
}
//链表不为空,找到尾节点
SLNode* TailList = *pphead;//解引用一次,即为第一个节点的地址
while (TailList->next)//当下一个节点不为NULL时继续循环
{
TailList = TailList->next;
}
//结束while后TailList即为尾节点,使尾节点指向新节点(更新尾节点)
TailList->next = newnode;
newnode->next = NULL;
}
- 做返回值
(1)返回局部变量的引用(不安全)
#include<iostream>
using namespace std;
int& func(int x)
{
return x;
}
int main()
{
int& ret = func(5);
cout << ret << endl;
cout << ret << endl;
return 0;
}
这种做法是不安全的,因为局部变量在函数返回后会自动销毁,第二次返回的引用将是悬挂引用,不再指向任何对象。
(2)返回静态局部变量的引用
静态局部变量存放在内存的全局数据区。函数结束时,静态局部变量不会消失,每次该函数调用 时,也不会为其重新分配空间。它始终驻留在全局数据区,直到程序运行结束。静态变量使用static关键字修饰。
#include<iostream>
using namespace std;
int& func(int x)
{
static int y = x;
return y;
}
int main()
{
int& ret = func(5);
cout << ret << endl;
cout << ret << endl;
return 0;
}
在返回静态局部变量时,它只被初始化一次,函数返回后也不会销毁,因此返回静态局部变量的引用时安全的
(3)返回常引用修饰的函数
如果函数返回一个常引用(const 引用),这表示返回的引用指向的对象不应被修改
#include<iostream>
using namespace std;
const int& func(int x)
{
static int y = x;
return y;
}
int main()
{
const int& ret = func(5);
cout << ret << endl;
cout << ret << endl;
return 0;
}
(4)返回对象成员的引用
返回对象成员的引用时,实际上是在返回该成员的内存地址的引用,而不是对象的副本。这可以避免不必要的拷贝,提高性能,尤其是对于大对象或复杂数据结构。
class Myclass{
public:
int val;
int& getValue()
{
return val;
}
};
由于是函数外部变量,函数栈帧销毁后外部变量仍然存在,因此这也是安全的。
总结:只要变量在出了函数作用域后仍然存在,就能使用引用返回
🦄 传值和传引用的效率比较
- 传值:传值过程中函数的形参是实参的拷贝(复制)
- 传引用:传引用过程中函数的形参是实参的引用(别名)
传值调用时,函数会创建一个临时变量保存实参的值,因此影响效率,相比之下,传引用调用时,由于直接操作的是实参的引用,没有额外的复制开销,因此在执行大型数据结构相关操作时,传引用调用的效率通常更高。
(1)测试它们当参数的效率
#include<iostream>
#include<time.h>
using namespace std;
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 < 200000; ++i)
TestFunc1(a);
size_t end1 = clock();
//以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 200000; ++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;
}
(2)测试它们作返回值的效率
#include<iostream>
#include<time.h>
using namespace std;
struct A
{
int a[10000];
};
A a;
A TestFunc1() {return a;}
A& TestFunc2() {return a;}
void TestRefAndValue()
{
//以值作为函数参数
size_t begin1 = clock();
for (size_t i = 0; i <200000; ++i)
TestFunc1();
size_t end1 = clock();
//以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 200000; ++i)
TestFunc2();
size_t end2 = clock();
//分别计算两个函数运行的时间
cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;
cout << "TestFunc2(A)-time:" << end2 - begin2 << endl;
}
int main()
{
TestRefAndValue();
return 0;
}
通过上述输出结果可以明显的看到,传引用的效率较高。
🐬 引用和指针的区别
- 引用:引用可以看作是某个变量的别名。一旦引用被创建,它就成为了另一个变量的同义词,对引用的操作将直接作用于它所引用的变量。引用的定义格式为“数据类型 &引用变量名 = 原变量名;”。
- 指针:指针是一种数据类型,用于存储变量的内存地址。通过指针,程序可以间接访问和操作内存中的数据。指针的定义格式为“数据类型 *指针变量名
从底层来看,引用也是按照指针的形式实现的
引用和指针的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址。
- 引用在定义时必须初始化,指针没有要求
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
#include<iostream>
using namespace std;
int main()
{
int x = 0;
int y = 1;
//只能引用一个实体
int& u = x;
u = y;//u保存的地址仍然是x的地址
//指针可以随时修改指向对象
int* p = &x;
p = &y;
return 0;
}
- 没有NULL引用,但有NULL指针
引用必须在初始化时绑定到一个有效的对象,且不能为 NULL 或 nullptr(C++11)。一旦引用被初始化,它必须始终指向一个有效的对象。
-
在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节,64位平台下占8个字节)
-
引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
-
有多级指针,但是没有多级引用
-
访问实体方式不同,指针需要显式解引用,引用编译器自己处理
-
引用比指针使用起来相对更安全
指针可以指向任何内存地址,包括非法的或未初始化的内存。这可能导致程序崩溃或不稳定。
引用在编译时会进行类型检查。此外,由于引用不能为空且不能改变,因此它们的使用相对更安全。
七、内联函数
🌅 概念
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
如果在函数名前加上inline使其变为内联函数,编译器会用函数体替代函数调用(无需建立栈帧,减少性能消耗)
如何查看是否为内联函数—>看汇编是否有call指令
- 在release版本:查看编译器产生的汇编代码是否含有call Swap1
- 在debug版本:需要对编译器进行设置,否则不会展开(因为在2013版本的vs后的debug版本下,编译器默认不对代码进行优化---->方便调试)
设置方式如下:
Q:函数体替代函数调用怎么理解?
当你定义一个 inline 函数时,编译器在处理代码时会尝试将这个函数的代码直接插入到每个调用点。这样做可以避免函数调用的开销(如入栈、出栈等),从而提高性能。展开后,函数调用就变成了函数体的代码片段。
🌄 特性
- inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率
Q:为什么可能会使目标文件变大?
函数体替代了函数调用:这是导致目标文件可能变大的直接原因。因为每个调用点都会复制一份函数体的代码,如果这个函数在程序中被多次调用,那么函数体的代码就会在目标文件中出现多次,从而增加了文件的大小。Q:减少调用开销怎么理解?
函数调用的开销主要包括保存和恢复寄存器的值、传递参数、以及返回值的处理。对于小型且频繁调用的函数,这些开销可能占据总执行时间的很大一部分。通过内联展开,这些开销可以被消除
- inline对于编译器只是一个建议,编译器可以选择忽略建议。因为inline一般只适用于优化规模较小、流程直接(非递归)、频繁调用的函数,否则编译器会自动忽略inline特性。——《C++primer第5版》
- inline不建议声明和定义分离,分离会导致链接错误。内联函数在调用过程中发现没有可以替换函数体的位置,那么就会去调用函数,而在符号表又找不到这个函数名(在汇编过程中,函数名也不会进入符号表,内联函数并没有一个独立的函数地址),所以链接找不到。
测试结果如下:
Q:inline被展开,就没有函数地址了,这句话如何理解?
- 使用inline关键字后,函数变为内联函数,代码在编译时被插入到调用点,结果是将调用语句替换成内联函数的函数体。内联展开后,调用这个函数就像直接写下函数体的代码一样。
- 由于函数的代码已经被插入到调用点,编译器不再需要在运行时通过函数地址跳转到函数体,因此在这种情况下,内联函数并没有一个独立的函数地址,在汇编过程中,函数名也不会进入符号表。
- C++中宏的替代
- 对于短小函数定义,用内联函数替代宏的使用
- 常量定义 用const 、enum替代宏的使用
八、auto关键字(C++11)
🥤 概念
auto关键字用于自动类型推导。它可以根据旧变量的初始化表达式自动确定新变量的类型,从而使代码更加简洁和易于阅读。当类型难于拼写或者含义不明确使用auto可以提升代码的安全性。
🍺 使用场景
- auto的简单使用
#include<iostream>
#include<vector>
#include<map>
using namespace std;
int value1()
{
return 9;
}
int main()
{
//自动推导为整形
auto i = 1;
//自动推导为浮点数
auto u = 1.02;
//自动推导为字符
auto c = 'z';
//自动推导为字符串
auto s = "string";
//自动推导函数返回类型
auto re = value1();
//自动推导容器类型
std::vector<string>v;
std::map<int, string>mp;
auto y = v;
auto p = mp;
//自动推导迭代器类型
std::vector<int>::iterator it;
auto it = v.begin();
return 0;
}
注意:使用 auto 定义新变量时等号表达式右边的值必须已经初始化,在编译阶段编译器需要根据初始化表达式来推导 auto 的实际类型。因此 auto 并非是一种"类型"的声明,而是一个类型声明时的"占位符",编译器在编译期会将 auto 替换为变量实际的类型。
- auto和指针/引用结合使用
auto和指针结合使用时,auto
和 auto*
效果一致,但是auto*
只能结合指针使用,其他类型不能使用
#include<iostream>
using namespace std;
int main()
{
int* p;
//二者作用相同
auto u = p;
auto* h = p;
return 0;
}
auto声明引用类型时则必须加&
#include<iostream>
using namespace std;
int main()
{
int a = 0;
auto& b = a;
return 0;
}
- 在同一行定义多个变量
变量类型必须一致,因为编译器只对第一个变量类型进行推导,用这个类型定义剩余变量
#include<iostream>
using namespace std;
int main()
{
auto p = 1, x = 8, y = 100;
//这里会报错,因为使用auto时,编译器只对第一个变量类型进行推导,用这个类型定义剩余变量
//auto x = 8, y = 0.5;
return 0;
}
插入一个知识:typeid().name
的作用和使用
(1)作用:typeid().name 用于获取一个对象或类型的类型信息
(2)使用:
#include<iostream>
#include<string>
int main()
{
int x = 9;
char b = 'p';
std::string s = "abcd";
std::cout << typeid(x).name() << std::endl;
std::cout << typeid(b).name() << std::endl;
std::cout << typeid(s).name() << std::endl;
return 0;
}
输出结果
🍹 注意事项
-
auto不能声明数组
-
auto 不能作为函数的参数
九、范围for循环(C++11)
🚆 概念
范围for是C++11引入的一种新的循环语法,它提供了一种更加简洁和直观的方式来遍历容器(如数组、vector、list等)或任何支持迭代器的序列。
🚇 工作原理
范围for循环通过调用序列的begin()和end()成员函数来获取迭代器,并使用这些迭代器在序列中迭代。在每次迭代中,它会将迭代器指向的当前元素赋值给声明的变量,并执行循环体。当迭代器到达序列的末尾时,循环结束。
🚊 基本使用及条件
- 基本使用
范围for的语法分为三个部分
(1)变量类型
(2)用于迭代的变量(默认是形参,可通过引用&指向实参)
(3)被迭代的容器
for(type e: arrays)
{
//循环体
}
示例:
使用范围for遍历数组
#include<iostream>
using namespace std;
int arr[5] = { 1,2,3,4,5 };
int main()
{
//auto:自动识别变量类型 e:容器的值(形参) arr:被迭代的容器
for (auto e : arr)
{
cout << e << ' ';
}
return 0;
}
测试结果
2. 使用条件
必须要传递一个完整的容器(有头有尾)
为什么,因为范围for的工作原理是调用迭代器的.begin()和.end()来遍历容器,从begin()到end()就是遍历范围。
以下面代码举例,因为容器的结束位置不明确,所以for的范围不明确
void Func(int array[])
{
for (auto x : array)
cout << x << endl;
}
数组传参传入的不是整个数组,而是指向数组第一个元素的指针,因此无法使用范围for
🚉 使用场景
范围for循环适用于所有定义了begin()和end()成员函数的容器类型,如std::vector、std::array、std::list、std::string等等。
- 遍历容器
#include<iostream>
#include<vector>
std::vector<int>v = { 1,2,3,4,5,6,7,8 };
int main()
{
for (auto e : v)
{
std::cout << e << ' ';
}
//输出:1 2 3 4 5 6 7 8
return 0;
}
- 修改容器的内容
范围for用于迭代的变量默认是形参,如果需要修改容器内容,使用引用&即可
#include<iostream>
#include<vector>
std::vector<int>v = { 1,2,3,4,5,6,7,8 };
int main()
{
for (auto e : v)
{
std::cout << e << ' ';
}
//输出:1 2 3 4 5 6 7 8
std::cout << std::endl;
//将容器所有变量乘2并输出
for (auto& e : v)
{
e * 2;
std::cout << e << ' ';
}
//输出:2 4 6 8 10 12 14 16
return 0;
}
十、指针空值nullptr(C++11)
nullptr
是为了弥补 C++ 98及之前版本NULL
的不足而诞生的。
在C++11出现之前,我们通常给一个没有指向的指针赋值为NULL
在C语言编程中,NULL
被广泛当成空指针使用,但是在C++中,NULL
被定义为0。
NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
NULL被定义为((void *)0)
和0
,即NULL不一定表示空指针,也有可以表示整形。
那么这会造成什么错误呢?
可以发现,我们传递了NULL指针,但是返回类型y却是int类型而不是int*类型,这与我们的预期不符。
因此,在C++11中,出现指针空值nullptr来填补这个bug。
注意
(1)使用nullptr无需包含头文件,它是作为C++11的关键字出现的,直接使用即可。
(2)在今后的C++编程中,我们都尽量使用nullptr代替NULL,提高代码的健壮性。
总结:nullptr和NULL都是用来表示空指针的,但nullptr提供了更好的类型安全性和避免了潜在的隐式类型转换问题,因此在C++编程中,使用nullptr是更优的选择。
结语
到这里,我们对C++的语法有了简单的认识,对于刚学习C++的同学来说,可能有些晦涩难懂,这需要我们在日常多多练习才能熟能生巧。
如果有什么建议或补充,亦或是错误,大家可以在评论区提出。
END
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)