文章目录
- 什么是C++?
- C++的发展史
- C++关键字(C++98)
- 命名空间
- 命名空间的定义
- 命名空间的使用
- C++中的输入和输出
- 缺省参数
- 缺省参数的概念
- 缺省参数分类
- 全缺省参数
- 半缺省参数
- 函数重载
- 函数重载的概念
- 函数重载的原理(名字修饰)
- extern
- #1. 符号的声明与定义
- #1.1 变量的声明与定义
- #1.2 函数的声明和定义:
- #2. C/C++ 中链接属性
- #2.1. 外部链接(External Linkage)
- #2.2 内部链接(Internal Linkage)
- #2.3 无链接(No Linkage)
- #2.4 外部 C 链接(External C Linkage)
- #3. extern 作用
- #3.1 声明变量但不定义
- #3.2 常量全局变量的外部链接
- #3.3 编译和链接过程
- extern “C”
- static
- #1. static 修饰全局变量
- #2. static 修饰局部变量
- 两种单例模式
- static 修饰类成员变量
- static 修饰局部变量
- #3. static 修饰函数
- #4. static 修饰类成员变量和函数
- Const
- 1. 修饰变量
- #2. 修饰函数参数,表示函数不会修改参数
- #3. 修饰函数返回值
- #4. 修饰指针或引用
- #4.1. 指向只读变量的指针
- #4.2 只读指针
- #4.3 只读指针指向只读变量
- #4.4 常量引用
- #5. 修饰成员函数
- mutable的作用
- #举个例子
- 引用
- 引用的概念
- 引用的特性
- 常引用
- 引用的使用场景
- 引用和指针的区别
- 内联函数
- 内联函数的概念
- 内联函数的特性
- auto关键字(C++11)
- auto简介
- auto的使用细则
- auto不能推导的场景
- 基于范围的for循环(C++11)
- 范围for的语法
- 范围for的使用条件
- new
- 语法
- 动态数组
- placement new
- 一、什么是placement new
- 二、用法
- **普通的new操作符分配一个对象的过程如下:**
- **placement new分配一个对象的过程如下:**
- 三 演示案例
- ①测试地址
- ②测试在内存上创建普通数据类型
- ③测试在内存上创建对象
- ④创建一个对象数组
- ⑤共享内存传递对象
- 初始化
- 内存泄漏
- 内存泄漏
- 分配内存策略
- 分配
- 指针空值nullptr
- C++98中的指针空值
- C++11中的指针空值
- 指针空值nullptr
- C++98中的指针空值
- C++11中的指针空值
本次内容大纲:
什么是C++?
C语言是结构化和模块化的语言,适合处理较小规模的程序。对于复杂的问题,规模较大的程序,需要高度的抽象和建模时,C语言则不合适。为了解决软件危机,20世纪80年代,计算机界提出了OOP(object oriented programming:面向对象) 思想,支持面向对象的程序设计语言应运而生。
1982年,Bjarne Stroustrup博士在C语言的基础上引入并扩充了面向对象的概念,发明了一种新的程序语言。为了表达该语言与C语言的渊源关系,命名为C++。因此,C++是基于C语言而产生的,它既可以进行C语言的过程化程序设计,又可以进行以抽象数据类型为特点的基于对象的程序设计,还可以进行面向对象的程序设计。
C++的发展史
1979年,贝尔实验室的本贾尼等人试图分析unix内核的时候,试图将内核模块化于是在C语言的基础上进行扩展,增加了类的机制,完成了一个可以运行的预处理程序,称之为C with classes。
语言的发展也是随着时代的进步,在逐步递进的,让我们来看看C++的历史版本:
阶段 | 内容 |
---|---|
C with classes | 类及派生类、公有和私有成员、类的构造析构、友元、内联函数、赋值运算符重载等 |
C++1.0 | 添加虚函数概念,函数和运算符重载,引用、常量等 |
C++2.0 | 更加完善支持面向对象,新增保护成员、多重继承、对象的初始化、抽象类、静态成员以及const成员函数 |
C++3.0 | 进一步完善,引入模板,解决多重继承产生的二义性问题和相应构造和析构的处理 |
C++98 | C++标准第一个版本,绝大多数编译器都支持,得到了国际标准化组织(ISO)和美国标准化协会认可,以模板方式重写C++标准库,引入了STL(标准模板库) |
C++03 | C++标准第二个版本,语言特性无大改变,主要∶修订错误、减少多异性 |
C++05 | C++标准委员会发布了一份计数报告(Technical Report,TR1),正式更名C++0x,即∶计划在本世纪第一个10年的某个时间发布 |
C++11 | 增加了许多特性,使得C++更像一种新语言,比如∶正则表达式、基于范围for循环、auto关键字、新容器、列表初始化、标准线程库等 |
C++14 | 对C++11的扩展,主要是修复C++11中漏洞以及改进,比如∶泛型的lambda表达式,auto的返回值类型推导,二进制字面常量等 |
C++17 | 在C++11上做了一些小幅改进,增加了19个新特性,比如∶static_assert()的文本信息可选,Fold表达式用于可变的模板,if和switch语句中的初始化器等 |
C++还在不断地向后发展…
C++关键字(C++98)
C++中总计有63个关键字:
不要觉得很多,其实这其中有32个是C语言中的关键字:
命名空间
在C/C++中,变量、函数和类都是大量存在的,这些变量、函数和类的名称都将作用于全局作用域中,可能会导致很多命名冲突。
使用命名空间的目的就是对标识符和名称进行本地化,以避免命名冲突或名字污染,namespace关键字的出现就是针对这种问题的。
命名空间的定义
定义命名空间,需要使用到 namespace 关键字,后面跟命名空间的名字,然后接一对{}即可,{}中即为命名空间的成员。
一、命名空间的普通定义
//命名空间的普通定义
namespace N1 //N1为命名空间的名称
{
//在命名空间中,既可以定义变量,也可以定义函数
int a;
int Add(int x, int y)
{
return x + y;
}
}
二、命名空间可以嵌套定义
//命名空间的嵌套定义
namespace N1 //定义一个名为N1的命名空间
{
int a;
int b;
namespace N2 //嵌套定义另一个名为N2的命名空间
{
int c;
int d;
}
}
三、同一个工程中允许存在多个相同名称的命名空间,编译器最后会将其成员合成在同一个命名空间中
所以我们不能在相同名称的命名空间中定义两个相同名称的成员。
注意:一个命名空间就定义了一个新的作用域,命名空间中所有内容都局限于该命名空间中。
命名空间的使用
现在我们已经知道了如何定义命名空间,那么我们应该如何使用命名空间中的成员呢?
命名空间的使用有以下三种方式:
一、加命名空间名称及作用域限定符
符号“::”在C++中叫做作用域限定符,我们通过“命名空间名称::命名空间成员”便可以访问到命名空间中相应的成员。
//加命名空间名称及作用域限定符
#include <stdio.h>
namespace N
{
int a;
double b;
}
int main()
{
N::a = 10;//将命名空间中的成员a赋值为10
printf("%d\n", N::a);//打印命名空间中的成员a
return 0;
}
二、使用using将命名空间中的成员引入
我们还可以通过“using 命名空间名称::命名空间成员”的方式将命名空间中指定的成员引入。这样一来,在该语句之后的代码中就可以直接使用引入的成员变量了。
//使用using将命名空间中的成员引入
#include <stdio.h>
namespace N
{
int a;
double b;
}
using N::a;//将命名空间中的成员a引入
int main()
{
a = 10;//将命名空间中的成员a赋值为10
printf("%d\n", a);//打印命名空间中的成员a
return 0;
}
三、使用using namespace 命名空间名称引入
最后一种方式就是通过”using namespace 命名空间名称“将命名空间中的全部成员引入。这样一来,在该语句之后的代码中就可以直接使用该命名空间内的全部成员了。
//使用using namespace 命名空间名称引入
#include <stdio.h>
namespace N
{
int a;
double b;
}
using namespace N;//将命名空间N的所有成员引入
int main()
{
a = 10;//将命名空间中的成员a赋值为10
printf("%d\n", a);//打印命名空间中的成员a
return 0;
}
C++中的输入和输出
我们在学C语言的时候,第一个写的代码就是在屏幕上输出一个"hello world",按照学习计算机语言的习俗,现在我们也应该使用C++来和这个世界打个招呼了:
#include <iostream>
using namespace std;
int main()
{
cout << "hello world!" << endl;
return 0;
}
在C语言中有标准输入输出函数scanf和printf,而在C++中有cin标准输入和cout标准输出。在C语言中使用scanf和printf函数,需要包含头文件stdio.h。在C++中使用cin和cout,需要包含头文件iostream以及std标准命名空间。
C++的输入输出方式与C语言相比是更加方便的,因为C++的输入输出不需要增加数据格式控制,例如:整型为%d,字符型为%c。
#include <iostream>
using namespace std;
int main()
{
int i;
double d;
char arr[20];
cin >> i;//读取一个整型
cin >> d;//读取一个浮点型
cin >> arr;//读取一个字符串
cout << i << endl;//打印整型i
cout << d << endl;//打印浮点型d
cout << arr << endl;//打印字符串arr
return 0;
}
注:代码中的endl的意思是输出一个换行符。
缺省参数
缺省参数的概念
缺省参数是指在声明或定义函数时,为函数的参数指定一个默认值。在调用该函数时,如果没有指定实参则采用该默认值,否则使用指定的实参。
#include <iostream>
using namespace std;
void Print(int a = 0)
{
cout << a << endl;
}
int main()
{
Print();//没有指定实参,使用参数的默认值(打印0)
Print(10);//指定了实参,使用指定的实参(打印10)
return 0;
}
缺省参数分类
全缺省参数
全缺省参数,即函数的全部形参都设置为缺省参数。
void Print(int a = 10, int b = 20, int c = 30)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
半缺省参数
半缺省参数,即函数的参数不全为缺省参数。
void Print(int a, int b, int c = 30)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
注意:
1、半缺省参数必须从右往左依次给出,不能间隔着给。
//错误示例
void Print(int a, int b = 20, int c)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
2、缺省参数不能在函数声明和定义中同时出现
//错误示例
//test.h
void Print(int a, int b, int c = 30);
//test.c
void Print(int a, int b, int c = 30)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
缺省参数只能在函数声明时出现,或者函数定义时出现(二者之一均正确)。
3、缺省值必须是常量或者全局变量。
//正确示例
int x = 30;//全局变量
void Print(int a, int b = 20, int c = x)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
函数重载
函数重载的概念
函数重载是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表必须不同。函数重载常用来处理实现功能类似,而数据类型不同的问题。
#include <iostream>
using namespace std;
int Add(int x, int y)
{
return x + y;
}
double Add(double x, double y)
{
return x + y;
}
int main()
{
cout << Add(1, 2) << endl;//打印1+2的结果
cout << Add(1.1, 2.2) << endl;//打印1.1+2.2的结果
return 0;
}
注意:形参列表不同是指参数个数、参数类型或者参数顺序不同,若仅仅是返回类型不同,则不能构成重载。
函数重载的原理(名字修饰)
为什么C++支持函数重载,而C语言不支持函数重载呢?
我们知道,一个C/C++程序要运行起来都需要经历以下几个阶段:预处理、编译、汇编、链接。
我们知道,在编译阶段会将程序中的每个源文件的全局范围的变量符号分别进行汇总。在汇编阶段会给每个源文件汇总出来的符号分配一个地址(若符号只是一个声明,则给其分配一个无意义的地址),然后分别生成一个符号表。最后在链接期间会将每个源文件的符号表进行合并,若不同源文件的符号表中出现了相同的符号,则取合法的地址为合并后的地址(重定位)。
在C语言中,汇编阶段进行符号汇总时,一个函数汇总后的符号就是其函数名,所以当汇总时发现多个相同的函数符号时,编译器便会报错。而C++在进行符号汇总时,对函数的名字修饰做了改动,函数汇总出的符号不再单单是函数的函数名,而是通过其参数的类型和个数以及顺序等信息汇总出 一个符号,这样一来,就算是函数名相同的函数,只要其参数的类型或参数的个数或参数的顺序不同,那么汇总出来的符号也就不同了。
注:不同编译器下,对函数名的修饰不同,但都是一样的。
总结:
1、C语言不能支持重载,是因为同名函数没办法区分。而C++是通过函数修饰规则来区分的,只要函数的形参列表不同,修饰出来的名字就不一样,也就支持了重载。
2、另外我们也理解了,为什么函数重载要求参数不同,根返回值没关系。
extern
一般而言,C++全局变量的作用范围仅限于当前的文件,但同时C++也支持分离式编译,允许将程序分割为若干个文件被独立编译。
于是就需要在文件间共享变量数据,这里extern就发挥了作用。
extern
用于指示变量或函数的定义在另一个源文件中,并在当前源文件中声明。 说明该符号具有外部链接(external linkage)属性。
也就是告诉编译器: 这个符号在别处定义了,你先编译,到时候链接器会去别的地方找这个符号定义的地址。
#1. 符号的声明与定义
首先明白 C/C++ 中变量的声明和定义是两个不同的概念。 声明是指告诉编译器某个符号的存在,在程序变量表中记录类型和名字,而定义则是指为该符号分配内存空间或实现其代码逻辑。
凡是没有带extern的声明同时也都是定义。 而对函数而言,带有{}是定义,否则是声明。如果想声明一个变量而非定义它,就在变量名前添加关键字extern
,且不要显式的初始化变量。
#1.1 变量的声明与定义
// 声明
extern int global_var;
// 定义
int global_var = 42;
在上面的示例中,global_var
变量的声明使用 extern
关键字告诉编译器它的定义在其他源文件中,而定义则是为变量分配内存空间并初始化为 42。
#1.2 函数的声明和定义:
// 声明
int sum(int a, int b);
// 定义
int sum(int a, int b) {
return a + b;
}
在上面的示例中,sum
函数的声明告诉编译器该函数的存在及其参数和返回值类型,而定义则是实现函数的代码逻辑。
#2. C/C++ 中链接属性
要彻底理解 extern 如何起作用的,需要了解一下 C/C++ 程序的 编译、链接过程,如果想要深入学习的话,请阅读 《程序员自我修养》 ,其中第二章开始就有讲到编译和链接
PDF 版本获取指南:系统编程相关书籍下载(opens new window)
在 C++ 中,链接属性是指程序在编译、链接和执行阶段如何处理符号(变量、函数、类等)的可见性和重复定义。 C++ 语言规定有以下链接属性:
#2.1. 外部链接(External Linkage)
外部链接的符号可以在不同的源文件之间共享,并且在整个程序执行期间可见。全局变量和函数都具有外部链接。
math.cpp
int a; // 声明 a void foo() { a++; }
LOG.cpp
int a = 10; int main() { a++; return 0; }
构建的时候报错。 全局变量有外部链接的属性
在C++中,当你在不同的编译单元(通常是不同的cpp文件)中定义了同一个全局变量(没有使用
extern
关键字声明为外部链接),链接器会在链接阶段发现这个变量的多重定义,从而报错。在你的例子中:
- 在LOG.cpp中,你定义了一个全局变量
int a = 10;
- 在Math.cpp中,你也定义了一个全局变量
int a;
,尽管没有初始化,但仍然是一个定义。当编译器分别编译这两个cpp文件生成OBJ文件后,在链接阶段,链接器会发现有两个
a
的定义,因此抛出LNK20错误(多重定义)。解决这个问题的方法是在其中一个cpp文件中(通常在使用该变量的cpp文件中)使用
extern
关键字声明变量a
而不定义它:// Math.cpp extern int a;
这样就告诉编译器
a
是在其他地方定义的,此处仅为引用。而在LOG.cpp中保留int a = 10;
作为全局变量的实际定义即可。
在C语言中,我们可以使用
extern
关键字来声明一个全局变量或函数,这个全局变量或函数是在其他源文件中定义的。这样就可以实现外部链接,使得它们在多个源文件之间共享并可见。例如,我们有两个源文件:
file1.c
和file2.c
。在
file1.c
中:// 定义一个全局变量 int globalVar = 10; // 定义一个全局函数 void func() { printf("This is a global function.\n"); }
在
file2.c
中:// 声明在file1.c中定义的全局变量和函数 extern int globalVar; extern void func(); int main() { // 使用全局变量 printf("The value of globalVar is: %d\n", globalVar); // 调用全局函数 func(); return 0; }
在这个例子中,
globalVar
和func
在file1.c
中定义并具有外部链接。在file2.c
中,我们通过extern
关键字声明了这两个全局实体,因此可以在main
函数中使用它们。这样,即使全局变量和函数的实际定义位于不同的源文件中,整个程序执行期间它们都是可见并可以共享的。
#2.2 内部链接(Internal Linkage)
内部链接的符号只能在当前源文件内部使用,不能被其他源文件访问。用 static
修饰的全局变量和函数具有内部链接。
#2.3 无链接(No Linkage)
无链接的符号只能在当前代码块(函数或代码块)内部使用,不能被其他函数或代码块访问。用 const
或 constexpr
修饰的常量具有无链接属性( 通常情况下编译器是不会为const对象分配内存,也就无法链接)。
在C++中,局部静态常量和
const
对象(非全局作用域)默认情况下具有无链接属性,它们只能在定义它们的函数或代码块内部访问。例如:
// file1.cpp #include <iostream> void func() { // 局部静态常量,无链接属性 static const int localConst = 10; std::cout << "In func(): localConst = " << localConst << std::endl; } int main() { func(); // 尝试访问func()中的局部静态常量,这是错误的,因为localConst只有在func()内部可见 // std::cout << "In main(): localConst = " << localConst << std::endl; // 这行会编译错误 return 0; }
在这个例子中,
localConst
是一个在func()
函数内部定义的局部静态常量,它具有无链接属性。因此,我们不能在main()
函数或其他任何函数中直接访问localConst
。然而,需要注意的是,虽然编译器可能不会为只在编译时就能确定值且没有外部引用的const对象分配内存(存储在编译器的符号表中),但这并不影响其链接属性。这里的"无链接"是指作用域而非存储位置,即变量的作用范围仅限于声明它的函数或代码块内。
#2.4 外部 C 链接(External C Linkage)
外部 C 链接的符号与外部链接类似,可以在不同的源文件之间共享,并且在整个程序执行期间可见。
它们具有 C 语言的名称和调用约定,可以与 C 语言编写的代码进行交互。
在 C++ 中,可以用 extern "C"
关键字来指定外部 C 链接,从而使用一些 C 的静态库。
// C库中的头文件,假设为my_c_library.h #ifdef __cplusplus extern "C" { #endif void c_function(int arg); #ifdef __cplusplus } #endif // 现在在C++源文件中使用这个C库函数 #include "my_c_library.h" int main() { // 调用C库中的函数 extern "C" { // 在这里也可以使用extern "C",但通常我们在头文件中处理 c_function(10); } return 0; }
#3. extern 作用
#3.1 声明变量但不定义
声明变量或函数的存在,但不进行定义,让编译器在链接时在其他源文件中查找定义。
这使得不同的源文件可以共享相同的变量或函数。
当链接器在一个全局变量声明前看到 extern 关键字,它会尝试在其他文件中寻找这个变量的定义。
这里强调全局且非常量的原因是,全局非常量的变量默认是外部链接的。
//fileA.cpp
int i = 1; //声明并定义全局变量i
//fileB.cpp
extern int i; //声明i,链接全局变量
//fileC.cpp
extern int i = 2; //错误,多重定义
int i; //错误,这是一个定义,导致多重定义
main()
{
extern int i; //正确
int i = 5; //正确,新的局部变量i;
}
#3.2 常量全局变量的外部链接
全局常量默认是内部链接的,所以想要在文件间传递全局常量量需要在定义时指明extern,如下所示:
//fileA.cpp
extern const int i = 1; //定义
//fileB.cpp //声明
extern const int i;
而下面这种用法则会报链接错误,找不到 i 的定义:
//fileA.cpp
const int i = 1; //定义 (不用 extern 修饰)
//fileB.cpp //声明
extern const int i;
#3.3 编译和链接过程
编译链接过程中,extern
的作用如下:
- 在编译期,
extern
用于告诉编译器某个变量或函数的定义在其他源文件中,编译器会为它生成一个符号表项,并在当前源文件中建立一个对该符号的引用。
这个引用是一个未定义的符号,编译器在后续的链接过程中会在其他源文件中查找这个符号的定义。
- 在链接期,链接器将多个目标文件合并成一个可执行文件,并且在当前源文件中声明的符号,会在其它源文件中找到对应的定义,并将它们链接起来。
下面是一个使用 extern
声明全局变量的示例:
// file1.cpp
#include <iostream>
extern int global_var;
int main() {
std::cout << global_var << std::endl;
return 0;
}
// file2.cpp
int global_var = 42;
在上面的示例中,file1.cpp
文件中的 main
函数使用了全局变量 global_var
,但是 global_var
的定义是在 file2.cpp
中的,因此在 file1.cpp
中需要使用 extern
声明该变量。
在编译时,编译器会为 global_var
生成一个符号表项,并在 file1.cpp
中建立一个对该符号的引用。
在链接时,链接器会在其他源文件中查找 global_var
的定义,并将其链接起来。
extern “C”
有时候在C++工程中可能需要将某些函数按照C的风格来编译,在函数前加“extern C”,意思是告诉编译器,将该函数按照C语言规则来编译。
注意:在函数前加“extern C”后,该函数便不能支持重载了。
static
在 C/C++ 中,static 是一个非常重要的关键字,它可以用于变量、函数和类中。
下面分别举例说明下:
#1. static 修饰全局变量
static 修饰全局变量可以将变量的作用域限定在当前文件中,使得其他文件无法访问该变量。 同时,static 修饰的全局变量在程序启动时被初始化(可以简单理解为在执行 main 函数之前,会执行一个全局的初始化函数,在那里会执行全局变量的初始化),生命周期和程序一样长。
// a.cpp 文件
static int a = 10; // static 修饰全局变量
int main() {
a++; // 合法,可以在当前文件中访问 a
return 0;
}
// b.cpp 文件
extern int a; // 声明 a
void foo() {
a++; // 非法,会报链接错误,其他文件无法访问 a
}
关于这里为什么会是非法、报链接错误,可以看看这篇文章:extern 的作用-从链接角度理解(opens new window)
了解一下 extern 和 链接方面的知识。
#2. static 修饰局部变量
static 修饰局部变量可以使得变量在函数调用结束后不会被销毁,而是一直存在于内存中,下次调用该函数时可以继续使用。
同时,由于 static 修饰的局部变量的作用域仅限于函数内部,所以其他函数无法访问该变量。
void foo() {
static int count = 0; // static 修饰局部变量
count++;
cout << count << endl;
}
int main() {
foo(); // 输出 1
foo(); // 输出 2
foo(); // 输出 3
return 0;
}
两种单例模式
static 修饰类成员变量
class Singleton { private: static Singleton* s_Instance; public: static Singleton& Get() { if (s_Instance == nullptr) { s_Instance = new Singleton(); } return *s_Instance; } void Hello() {}; }; Singleton* Singleton::s_Instance = nullptr; int main() { Singleton& s = Singleton::Get(); s.Hello(); // ... } 在这个版本中,Singleton实例是在首次调用Get()函数时创建的。这种实现方式被称为“懒汉式”单例,它在需要时才进行初始化。但请注意,这个简单的实现并没有考虑线程安全问题,在多线程环境下可能需要添加互斥锁。
static 修饰局部变量
#include<iostream> using std::cout; using std::cin; using std::endl; class Singleton { private: //static Singleton* s_Instance; public: static Singleton& Get() { static Singleton s_instance; return s_instance; } void Hello() {}; }; //Singleton* Singleton::s_Instance = nullptr; // 访问: int main() { Singleton& s = Singleton::Get(); s.Hello(); }
在这个版本中,Singleton
实例是在Get()
函数内部通过static
局部变量的方式进行初始化的,这确保了在程序运行到该点时只会初始化一次,即“饿汉式”单例。由于C++标准保证了静态局部变量的线程安全性,所以这种方式在多线程环境下是线程安全的。
安全的饿汉模式
#include <mutex>
class Singleton {
private:
// 私有化构造函数和拷贝构造、赋值运算符,防止外部实例化和拷贝
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 饿汉式单例,静态成员变量在类加载时就初始化
static Singleton instance;
static std::once_flag onceFlag; // 用于确保线程安全初始化
public:
// 提供全局访问点,返回单例对象的引用
static Singleton& GetInstance() {
std::call_once(onceFlag, []{});
return instance;
}
void Hello() {
// 单例类的其他成员函数
}
};
// 在类外定义并初始化静态成员变量(这里采用了C++11的列表初始化)
Singleton Singleton::instance;
std::once_flag Singleton::onceFlag;
// 使用示例:
int main() {
Singleton& s = Singleton::GetInstance();
s.Hello();
return 0;
}
在这个版本中,Singleton
类通过std::once_flag
和std::call_once
来保证多线程环境下的线程安全性。同时,我们采用“饿汉式”单例模式,在类加载时即初始化静态成员变量instance
,因此无需担心并发问题。
#3. static 修饰函数
static 修饰函数可以将函数的作用域限定在当前文件中,使得其他文件无法访问该函数。
同时,由于 static 修饰的函数只能在当前文件中被调用,因此可以避免命名冲突和代码重复定义。
// a.cpp 文件
static void foo() { // static 修饰函数
cout << "Hello, world!" << endl;
}
int main() {
foo(); // 合法,可以在当前文件中调用 foo 函数
return 0;
}
// b.cpp 文件
extern void foo(); // 声明 foo
void bar() {
foo(); // 非法,会报链接错误,找不到 foo 函数,其他文件无法调用 foo 函数
}
#4. static 修饰类成员变量和函数
类成员变量 需要 在类外进行成员初始化。
类成员 函数 不需要 在类外进行成员初始化
static 修饰类成员变量和函数可以使得它们在所有类对象中共享,且不需要创建对象就可以直接访问。
class MyClass {
public:
static int count; // 静态成员变量,在整个类范围内只有一个实例
static void foo() { // 静态成员函数,可以通过类名直接调用
cout << count << endl;
}
};
// 在类外进行静态成员变量的初始化
int MyClass::count = 0;
// 不需要对静态成员函数进行重复声明(你的例子中的这一行是多余的)
// void MyClass::foo() ;
// 下面这两行尝试直接访问静态成员变量和调用静态成员函数,但在全局作用域下没有意义
// MyClass::count;
// MyClass::foo();
// 正确的访问方式是在main函数或者其他适当的地方
int main(){
MyClass::count = 5; // 设置静态成员变量的值
MyClass::foo(); // 调用静态成员函数,输出count的当前值
return 0;
}
Const
1. 修饰变量
当 const
修饰变量时,该变量将被视为只读变量,即不能被修改。
对于确定不会被修改的变量,应该加上 const,这样可以保证变量的值不会被无意中修改,也可以使编译器在代码优化时更加智能。
例如:
const int a = 10;
a = 20; // 编译错误,a 是只读变量,不能被修改
但是!!!请注意!!!
这里的变量只读,其实只是编译器层面的保证,实际上可以通过指针在运行时去间接修改这个变量的值,当然这个方法比较trick。
对 const int
类型取指针,就是 const int*
类型的指针,将其强制转换为 int*
类型,就去掉了 const
限制,从而修改变量的值。
在 C++ 中,将 const
类型的指针强制转换为非const
类型的指针被称为类型强制转换(Type Casting),这种行为称为 const_cast
。
关于 const_cast 可以看下这篇文章: C++几种类型转换的区别(opens new window)
虽然可以这样操作,但这违反了 const
的语义,可能会导致程序崩溃或者产生未定义行为(undefined behavior),大家学习了解即可,实际编程中切莫如此操作。
因为编译器可能会做一些优化!!也就是在你用到 const 变量的地方,编译器可能生成的代码直接就替换为常量的值,而不是访问一遍常量的指令。
所以极大可能你虽然修改了值,但是却不起作用!
下面👇这个例子,展示了使用 const_cast
修改 const
变量的值却不会起作用:
const int a = 10;
const int* p = &a;
int* q = const_cast<int*>(p);
*q = 20; // 通过指针间接修改 const 变量的值
std::cout << "a = " << a << std::endl; // 输出 a 的值,结果为 10
在上面的例子中,将 p
声明为 const int*
类型,指向只读变量 a
的地址。
然后使用 const_cast
将 p
强制转换为 int*
类型的指针 q
,从而去掉了 const
限制。
接下来,通过指针 q
间接修改了变量 a
的值。
但是请注意,即使 a
的值被修改了,但在程序中输出a
的值仍然是 10,
正如前面所有,因为 a
是只读变量,所以编译器做了优化,早就把代码实际替换为了👇下面这样:
std::cout << "a = " << 10 << std::endl;
所以,咱们还是要老老实实按照语言标准编程,切莫搞各种骚操作。
总之,使用 const_cast
去掉 const
限制是不推荐的,这会破坏程序的正确性和稳定性。
我们应该遵循 C/C++ 语言中 const 的语义,尽量不修改只读变量的值。
#2. 修饰函数参数,表示函数不会修改参数
当 const
修饰函数参数时,表示函数内部不会修改该参数的值。这样做可以使代码更加安全,避免在函数内部无意中修改传入的参数值。
尤其是 引用 作为参数时,如果确定不会修改引用,那么一定要使用 const 引用。
例如:
void func(const int a) {
// 编译错误,不能修改 a 的值
a = 10;
}
#3. 修饰函数返回值
当 const
修饰函数返回值时,表示函数的返回值为只读,不能被修改。这样做可以使函数返回的值更加安全,避免被误修改。
例如:
const int func() {
int a = 10;
return a;
}
int main() {
const int b = func(); // b 的值为 10,不能被修改
b = 20; // 编译错误,b 是只读变量,不能被修改
return 0;
}
#4. 修饰指针或引用
在 C/C++ 中,const 关键字可以用来修饰指针,用于声明指针本身为只读变量或者指向只读变量的指针。
根据 const 关键字的位置和类型,可以将 const 指针分为以下三种情况:
#4.1. 指向只读变量的指针
这种情况下,const 关键字修饰的是指针所指向的变量,而不是指针本身。
因此,指针本身可以被修改(意思是指针可以指向新的变量),但是不能通过指针修改所指向的变量。
const int* p; // 声明一个指向只读变量的指针,可以指向 int 类型的只读变量
int a = 10;
const int b = 20;
p = &a; // 合法,指针可以指向普通变量
p = &b; // 合法,指针可以指向只读变量
*p = 30; // 非法,无法通过指针修改只读变量的值
在上面的例子中,我们使用 const int*
声明了一个指向只读变量的指针 p
。
我们可以将指针指向普通变量或者只读变量,但是无法通过指针修改只读变量的值。
#4.2 只读指针
这种情况下,const 关键字修饰的是指针本身,使得指针本身成为只读变量。
因此,指针本身不能被修改(即指针一旦初始化就不能指向其它变量),但是可以通过指针修改所指向的变量。
int a = 10;
int b = 20;
int* const p = &a; // 声明一个只读指针,指向 a
*p = 30; // 合法,可以通过指针修改 a 的值
p = &b; // 非法,无法修改只读指针的值
在上面的例子中,我们使用 int* const
声明了一个只读指针 p
,指向变量 a
。我们可以通过指针修改 a
的值,但是无法修改指针的值。
#4.3 只读指针指向只读变量
这种情况下,const 关键字同时修饰了指针本身和指针所指向的变量,使得指针本身和所指向的变量都成为只读变量。
因此,指针本身不能被修改,也不能通过指针修改所指向的变量。
const int a = 10;
const int* const p = &a; // 声明一个只读指针,指向只读变量 a
*p = 20; // 非法,无法通过指针修改只读变量的值
p = nullptr; // 非法,无法修改只读指针的值
#4.4 常量引用
常量引用是指引用一个只读变量的引用,因此不能通过常量引用修改变量的值。
const int a = 10;
const int& b = a; // 声明一个常量引用,引用常量 a
b = 20; // 非法,无法通过常量引用修改常量 a 的值
#5. 修饰成员函数
当 const
修饰成员函数时,表示该函数不会修改对象的状态(就是不会修改成员变量)。
这样有个好处是,const 的对象就可以调用这些成员方法了,因为 const 对象不允许调用非 const 的成员方法。
也很好理解,既然对象是 const 的,那我怎么保证调用完这个成员方法,你不会修改我的对象成员变量呢?那就只能你自己把方法声明未 const 的呢~
例如:
class A {
public:
int func() const {
// 编译错误,不能修改成员变量的值
m_value = 10;
return m_value;
}
private:
int m_value;
};
这里还要注意,const 的成员函数不能调用非 const 的成员函数,原因在于 const 的成员函数保证了不修改对象状态,但是如果调用了非 const 成员函数,那么这个保证可能会被破坏。
总之,const
关键字的作用是为了保证变量的安全性和代码可读性。
mutable的作用
mutable
是C++中的一个关键字,用于修饰类的成员变量,表示该成员变量即使在一个const
成员函数中也可以被修改。
mutable的中文意思是“可变的,易变的”,跟constant(既C++中的const)是反义词
因为在C++中,如果一个成员函数被声明为const
,那么它不能修改类的任何成员变量,除非这个成员变量被声明为mutable
。
看起来有点奇怪是不是,我也觉得,所以实际上这个关键字我在工作中几乎从来没看见过。。。
但是还是掌握以下吧哈哈
这个关键字主要应用场景就是:
如果需要在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后后面关键字位置。
#举个例子
#include <iostream>
class Counter {
public:
Counter() : count(0), cache_valid(false), cached_value(0) {}
int get_count() const {
if (!cache_valid) {
// 模拟一个耗时的计算过程
cached_value = count * 2;
cache_valid = true;
}
return cached_value;
}
void increment() {
count++;
cache_valid = false; // 使缓存无效,因为count已经更改
}
private:
int count;
mutable bool cache_valid; // 缓存是否有效的标志
mutable int cached_value; // 缓存的值
};
int main() {
Counter counter;
counter.increment();
counter.increment();
std::cout << "Count: " << counter.get_count() << std::endl; // 输出 4
return 0;
}
上面定义了一个Counter
类,该类具有一个计数成员变量count
。还有两个mutable
成员变量:cache_valid
和cached_value
。
这两个变量用于在get_count
函数中缓存计算结果,从而提高性能。
get_count
函数被声明为const
,因为它在逻辑上不会更改类的状态。
然而,需要更新cache_valid
和cached_value
变量以提高性能。
为了在const
成员函数中修改这两个变量,将它们声明为mutable
。
这个例子不那么贴切的展示了mutable
关键字的用途:
即允许在const
成员函数中修改特定的成员变量,以支持内部实现所需的功能,同时仍然保持外部不变性。
mutable主要用于优化内部实现,允许在const成员函数中修改某些看似与对象状态无关但实际上用于优化或辅助计算的变量。
在上述Counter类的例子中,cache_valid和cached_value两个mutable成员变量是为了缓存计算结果(这里假设实际的计算过程非常耗时)。当调用get_count() const成员函数时,虽然从逻辑上看并没有改变对象的主要状态(即count),但是为了提高性能,需要更新这两个mutable变量来存储并验证缓存的有效性。
通过mutable关键字修饰这两个成员变量,使得即使在const成员函数中也能修改它们,从而保持了外部接口的不变性(对于外部用户来说,调用const函数不会更改对象的状态),同时实现了内部逻辑的优化。
引用
引用的概念
引用不是定义一个变量,而是已存在的变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
其使用的基本形式为:类型& 引用变量名(对象名) = 引用实体。
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int& b = a;//给变量a去了一个别名,叫b
cout << "a = " << a << endl;//a打印结果为10
cout << "b = " << b << endl;//b打印结果也是10
b = 20;//改变b也就是改变了a
cout << "a = " << a << endl;//a打印结果为20
cout << "b = " << b << endl;//b打印结果也是为20
return 0;
}
注:引用类型必须和引用实体是同种类型。
引用的特性
一、引用在定义时必须初始化
正确示例:
int a = 10;
int& b = a;//引用在定义时必须初始化
错误示例:
int c = 10;
int &d;//定义时未初始化
d = c;
二、一个变量可以有多个引用
例如:
int a = 10;
int& b = a;
int& c = a;
int& d = a;
此时,b、c、d都是变量a的引用。
三、引用一旦引用了一个实体,就不能再引用其他实体
例如:
int a = 10;
int& b = a;
此时,b已经是a的引用了,b不能再引用其他实体。如果你写下以下代码,想让b转而引用另一个变量c:
int a = 10;
int& b = a;
int c = 20;
b = c;//你的想法:让b转而引用c
但该代码并没有随你的意,该代码的意思是:将b引用的实体赋值为c,也就是将变量a的内容改成了20。
常引用
上面提到,引用类型必须和引用实体是同种类型的。但是仅仅是同种类型,还不能保证能够引用成功,我们若用一个普通引用类型去引用其对应的类型,但该类型被const所修饰,那么引用将不会成功。
int main()
{
const int a = 10;
//int& ra = a; //该语句编译时会出错,a为常量
const int& ra = a;//正确
//int& b = 10; //该语句编译时会出错,10为常量
const int& b = 10;//正确
return 0;
}
我们可以将被const修饰了的类型理解为安全的类型,因为其不能被修改。我们若将一个安全的类型交给一个不安全的类型(可被修改),那么将不会成功。
引用的使用场景
一、引用做参数
还记得C语言中的交换函数,学习C语言的时候经常用交换函数来说明传值和传址的区别。现在我们学习了引用,可以不用指针作为形参了:
//交换函数
void Swap(int& a, int& b)
{
int tmp = a;
a = b;
b = tmp;
}
因为在这里a和b是传入实参的引用,我们将a和b的值交换,就相当于将传入的两个实参交换了。
二、引用做返回值
当然引用也能做返回值,但是要特别注意,我们返回的数据不能是函数内部创建的普通局部变量,因为在函数内部定义的普通的局部变量会随着函数调用的结束而被销毁。我们返回的数据必须是被static修饰或者是动态开辟的或者是全局变量等不会随着函数调用的结束而被销毁的数据。
int& Add(int a, int b)
{
static int c = a + b;
return c;
}
5
注意:如果函数返回时,出了函数作用域,返回对象还未还给系统,则可以使用引用返回;如果已经还给系统了,则必须使用传值返回。
引用和指针的区别
在语法概念上,引用就是一个别名,没有独立的空间,其和引用实体共用同一块空间。
int main()
{
int a = 10;
//在语法上,这里给a这块空间取了一个别名,没有新开空间
int& ra = a;
ra = 20;
//在语法上,这里定义了一个pa指针,开辟了4个字节(32位平台)的空间,用于存储a的地址
int* pa = &a;
*pa = 20;
return 0;
}
但是在底层实现上,引用实际是有空间的:
从汇编角度来看,引用的底层实现也是类似指针存地址的方式来处理的。
引用和指针的区别(重要):
1、引用在定义时必须初始化,指针没有要求。
2、引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体。
3、没有NULL引用,但有NULL指针。
4、在sizeof中的含义不同:引用的结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)。
5、引用进行自增操作就相当于实体增加1,而指针进行自增操作是指针向后偏移一个类型的大小。
6、有多级指针,但是没有多级引用。
7、访问实体的方式不同,指针需要显示解引用,而引用是编译器自己处理。
8、引用比指针使用起来相对更安全。
内联函数
内联函数的概念
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数的使用可以提升程序的运行效率。
我们可以通过观察调用普通函数和内联函数的汇编代码来进一步查看其优势:
int Add(int a, int b)
{
return a + b;
}
int main()
{
int ret = Add(1, 2);
return 0;
}
下图左是以上代码的汇编代码,下图右是函数Add加上inline后的汇编代码:
从汇编代码中可以看出,内联函数调用时并没有调用函数这个过程的汇编指令。
内联函数的特性
1、inline是一种以空间换时间的做法,省了去调用函数的额外开销。由于内联函数会在调用的位置展开,所以代码很长或者有递归的函数不适宜作为内联函数。频繁调用的小函数建议定义成内联函数。
2、inline对于编译器而言只是一个建议,编译器会自动优化,如果定义为inline的函数体内有递归等,编译器优化时会忽略掉内联。
3、inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了链接就会找不到。
auto关键字(C++11)
auto简介
在早期的C/C++中auto的含义是:使用auto修饰的变量是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它。
在C++11中,标准委员会赋予了auto全新的含义:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
#include <iostream>
using namespace std;
double Fun()
{
return 3.14;
}
int main()
{
int a = 10;
auto b = a;
auto c = 'A';
auto d = Fun();
//打印变量b,c,d的类型
cout << typeid(b).name() << endl;//打印结果为int
cout << typeid(c).name() << endl;//打印结果为char
cout << typeid(d).name() << endl;//打印结果为double
return 0;
}
18
注意:使用auto变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此,auto并非是一种“类型”的声明,而是一个类型声明的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
auto的使用细则
一、auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时必须加&。
#include <iostream>
using namespace std;
int main()
{
int a = 10;
auto b = &a; //自动推导出b的类型为int*
auto* c = &a; //自动推导出c的类型为int*
auto& d = a; //自动推导出d的类型为int
//打印变量b,c,d的类型
cout << typeid(b).name() << endl;//打印结果为int*
cout << typeid(c).name() << endl;//打印结果为int*
cout << typeid(d).name() << endl;//打印结果为int
return 0;
}
注意:用auto声明引用时必须加&,否则创建的只是与实体类型相同的普通变量。
二、在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
int main()
{
auto a = 1, b = 2; //正确
auto c = 3, d = 4.0; //编译器报错:“auto”必须始终推导为同一类型
return 0;
}
auto不能推导的场景
一、auto不能作为函数的参数
以下代码编译失败,auto不能作为形参类型,因为编译器无法对x的实际类型进行推导。
void TestAuto(auto x)
{}
二、auto不能直接用来声明数组
int main()
{
int a[] = { 1, 2, 3 };
auto b[] = { 4, 5, 6 };//error
return 0;
}
基于范围的for循环(C++11)
范围for的语法
若是在C++98中我们要遍历一个数组,可以按照以下方式:
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
//将数组元素值全部乘以2
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
{
arr[i] *= 2;
}
//打印数组中的所有元素
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
{
cout << arr[i] << " ";
}
cout << endl;
以上方式也是我们C语言中所用的遍历数组的方式,但对于一个有范围的集合而言,循环是多余的,有时还容易犯错。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
//将数组元素值全部乘以2
for (auto& e : arr)
{
e *= 2;
}
//打印数组中的所有元素
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
注意:与普通循环类似,可用continue来结束本次循环,也可以用break来跳出整个循环。
范围for的使用条件
一、for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
二、迭代的对象要实现++和==操作
这是关于迭代器的问题,大家先了解一下。
new
语法
new 表达式在 C++ 中用于动态分配内存并构造对象或对象数组。其基本形式如下:
new type(optional-initializer);
- 当类型包含圆括号时,为了正确解析,需要明确指出是类型说明而非函数调用,如第二个例子所示。
new int(*[10])(); // 错误:会解析成 (new int) (*[10]) ()
new (int (*[10])()); // OK:分配 10 个函数指针的数组
- 类型解析遵循贪心原则,尽可能多地包含记号作为类型的一部分,因此在
new int + 1
和new int * 1
的例子中,第一个被解析为增加 new 表达式返回的指针值,而第二个是错误的语法。
new int + 1; // OK:解析成 (new int) + 1,增加 new int 返回的指针
new int * 1; // 错误:解析成 (new int*) (1)
- 在以下情况下,new 表达式必须带有初始化器:
- 动态分配未知边界数组(例如:
new int[] {...}
)。 - 使用了
auto
或decltype(auto)
作为类型,并结合了类型约束(C++14 起)。 - 分配类模板实例时,且该模板有需要推导的实参(C++17 起)。
- 动态分配未知边界数组(例如:
double* p = new double[]{1, 2, 3}; // 创建 double[3] 类型的数组
auto p = new auto('c'); // 创建单个 char 类型的对象。p 是一个 char*
auto q = new std::integral auto(1); // OK: q 是一个 int*
auto q = new std::floating_point auto(true) // 错误:不满足类型约束
auto r = new std::pair(1, true); // OK: r 是一个 std::pair<int, bool>*
auto r = new std::vector; // 错误:无法推导元素类型
-
示例中,
double* p = new double[]{1, 2, 3};
创建了一个具有三个元素的double
类型数组,并返回指向其首元素的指针。 -
类型推导的例子中,
auto p = new auto('c');
创建了一个char
类型的对象,因为'c'
是一个字符字面量,所以p
的类型是一个指向char
的指针。 -
对于类型约束的例子,
std::integral auto
确保推导出的类型为整型,因此auto q = new std::integral auto(1);
的q
是一个指向int
的指针。而尝试使用std::floating_point auto(true)
则会失败,因为布尔值无法满足浮点数类型约束。 -
最后,
std::pair
的例子中,new std::pair(1, true);
可以成功创建一个std::pair<int, bool>
类型的对象,并返回指向它的指针。然而,对于没有指定元素类型的std::vector
,编译器无法推导出元素类型,所以new std::vector;
是错误的。
动态数组
在C++中,动态数组的创建可以通过 new
表达式完成。对于多维数组,除了第一维外,其他维度必须是正的整数常量表达式(C++14之前),或者能够转换为std::size_t
的常量表达式(C++14及以后)。而对于第一维,在C++14之前,可以接受整数类型、枚举类型或具有到整数或枚举类型的非explicit转换函数的类类型;在C++14及以后,则可以接受任何能转换成std::size_t
的表达式。
例如:
int n = 42;
double a[n][5]; // 错误:在栈上创建时,第一维不能是变量
auto p1 = new double[n][5]; // OK:动态创建n×5的二维数组
然而,如果第一维的值在转换后为负数,则行为未定义。另外,在C++11之前,若第一维表达式经过转换后仍不符合要求(如负数、超过实现定义的极限或小于初始化器元素数量),则会导致错误或抛出异常。
从C++11开始,第一维为零是合法的,分配函数依然会被调用。如果使用花括号初始化器列表,并且第一维是一个潜在求值的非核心常量表达式,编译器会检查是否满足对空初始化器列表复制初始化数组中虚设元素的语义约束。
以下是一个C++代码示例,展示了动态分配零大小数组以及使用花括号初始化器列表的情况:
#include <iostream> int main() { // 假设我们从某个函数获取第一维度的大小,该值可能为0 int x = getDimensionAtRuntime(); // 假设此函数返回一个整数,可能是0 // 动态分配二维数组,其中第一维是运行时确定的,且可能为0 int (*arr)[5] = new int[x][5]; // 假设第二维度固定为5 // 如果x为0,实际上分配的是一个大小为0的第一维度数组 if (x == 0) { std::cout << "Allocated a 0x5 array: " << arr << std::endl; delete[] arr; // 即使没有元素,也需要正确释放内存 } else { // 对于非零尺寸,假设我们有对应的初始化数据 // 注意:即使x为0,下面的初始化也是合法的语法,但不会执行任何实际的初始化 // 因为数组本身并没有元素 int initData[] = {1, 2, 3, 4, 5}; for (int i = 0; i < x; ++i) { std::copy_n(initData, 5, arr[i]); } // 使用和处理数组... delete[] arr; } return 0; } // 假设的运行时获取维度大小的函数 int getDimensionAtRuntime() { // 这里只是一个模拟,实际可能会根据程序逻辑计算出不同值 return 0; }
在这个例子中,
new int[x][5]
分配了一个可能大小为0的第一维度数组。由于C++11允许创建大小为0的数组,因此无论x
是什么值,都会成功分配内存并返回一个合法指针。然而,当
x
为0时,即使后面跟了花括号初始化器(此处未展示),也不会有任何元素被初始化,因为没有元素存在。编译器会检查初始化表达式的合法性,即使在这种情况下初始化过程没有任何实际操作。
总结:
- 动态数组创建的基本语法:
new type[dimension] {optional-initializer}
- 第一维的大小可以在运行时决定。
- 其他维度的大小必须是编译期可确定的正整数值。
- 对于第一维大小的限制和处理规则随着C++标准版本的变化而有所不同。
placement new
一、什么是placement new
- placement new就是在用户指定的内存位置上(这个内存是已经预先分配好的)构建新的对象,因此这个构建过程不需要额外分配内存,只需要调用对象的构造函数在该内存位置上构造对象即可
- 语法格式:
- address:placement new所指定的内存地址
- ClassConstruct:对象的构造函数
Object * p = new (address) ClassConstruct(...);
- 优点:
- 在已分配好的内存上进行对象的构建,构建速度快
- 已分配好的内存可以反复利用,有效的避免内存碎片问题
二、用法
- 下面与普通的new操作符来做比较,演示placement new的用法:
普通的new操作符分配一个对象的过程如下:
- ①使用new分配对象内存(堆中)
- ②调用对象类的构造函数在该内存地址创建对象
- 例如:
int *p = new int(1);
placement new分配一个对象的过程如下:
- ①使用new引用一个已经分配好的内存
- ②调用对象类的构造函数在该内存地址上创建对象
- 例如:
//先分配一对内存 int* buff = new int; memset(buff,0,sizeof(int)); //此处new的placement new,在buff的内存上构造int对象,不需要分配额外的内存 int *p = new (buff)int(3); std::cout << *p << std::endl; /
三 演示案例
①测试地址
#include <iostream>
#include <string.h>
using std::cout;
using std::cin;
using std::endl;
int main()
{
char *buff=new char[sizeof(int)];
memset(buff,0,sizeof(int));
// std::cout<<"buff address:"<<buff<<std::endl;
int *p1=new(buff) int(1);
std::cout<<"p1:"<<std::endl;
std::cout<<" "<<"address:"<<p1<<std::endl;
std::cout<<" "<<"value:"<<*p1<<std::endl;
p1=nullptr;
int *p2=new(buff) int(2);
std::cout<<"p2:"<<std::endl;
std::cout<<" "<<"address:"<<p2<<std::endl;
std:cout<<" "<<"value:"<<*p2<<std::endl;
p2=nullptr;
//不要忘记释放内存
delete [] buff;
return 0;
}
- 可以看到两个指针都是用了同一块地址的内存
②测试在内存上创建普通数据类型
#include <iostream>
#include <string.h>
#include <new>
using std::cin;
using std::cout;
using std::endl;
int main()
{
//先分配一对内存
int* buff = new int;
memset(buff,0,sizeof(int));
//此处new的placement new,在buff的内存上构造int对象,不需要分配额外的内存
int *p = new (buff)int(3);
std::cout << *p << std::endl; //3
return 0;
}
- 如果将代码改为下面所示格式也会产生相同的结果
#include <iostream>
#include <string.h>
#include <new>
using std::cin;
using std::cout;
using std::endl;
int main()
{
//先分配一对内存
char* buff = new char[sizeof(int)];
memset(buff,0,sizeof(buff));
//此处new的placement new,在buff的内存上构造int对象,不需要分配额外的内存
int *p = new (buff)int(3);
std::cout << *p << std::endl; //3
return 0;
}
③测试在内存上创建对象
#include <iostream>
#include <string>
#include <string.h>
using std::cout;
using std::cin;
using std::endl;
class testClass
{
public:
testClass(int data) :data(data) {}
int getData() { return this->data; }
void setData(int data) { this->data = data; }
private:
int data;
};
int main()
{
//申请一个testClass类大小的动态内存
char* buff = new char[sizeof(testClass)];
memset(buff, 0, sizeof(int));
//placement new一个对象
testClass* myClass = new (buff)testClass(10);
std::cout << myClass->getData() << std::endl;
//使用完之后调用析构函数销毁对象并置空(但是buff动态内存仍存在)
myClass->~testClass();
myClass = nullptr;
//在这块内存上再次分配一个对象
testClass* myClass2 = new (buff)testClass(12);
std::cout << myClass2->getData() << std::endl;
//释放对象
myClass2->~testClass();
myClass2 = nullptr;
//释放动态内存
delete[]buff;
return 0;
}
④创建一个对象数组
#include <iostream>
#include <string>
using std::cout;
using std::cin;
using std::endl;
class testClass
{
public:
testClass(int data) :data(data) {}
int getData() { return this->data; }
void setData(int data) { this->data = data; }
private:
int data;
};
int main()
{
//申请10个testClass类大小的动态内存
char* buff = new char[sizeof(testClass) * 10];
memset(buff, 0, sizeof(buff));
//将buff的首地址赋值给一个testClass类
testClass* start = (testClass*)buff;
//循环
for (int i = 0; i < 10; ++i) {
new (start + i)testClass(i);//placement new一个testClass对象
std::cout << "class" << i + 1 << ":" << (start + i)->getData() << std::endl;
(start + i)->~testClass();//使用完之后释放对象(但是动态内存仍存在)
}
//最后是释放动态内存
delete[] buff;
return 0;
}
⑤共享内存传递对象
- 在进程间使用共享内存的时候,C++的placement new经常被用到。例如主进程分配共享内容,然后在共享内存上创建C++类对象,然后从进程直接attach到这块共享内容,拿到类对象,直接访问类对象的变量和函数
- 1.主进程以server的方式启动
- 分配共享内存
- 在共享内存上通过placement new创建对象SHMObj
- 2.从进程以普通方式启动
- attach到主进程的共享内存
- 拿到代表SHMObj对象的指针
初始化
以下是上述规则在C++代码中的示例:
- 非数组类型对象初始化:
// 没有 new 初始化器,对象被默认初始化
int* p1 = new int; // 默认初始化为0(对于内置类型int)
std::string* s1 = new std::string; // 默认初始化为空字符串
// new 初始化器是圆括号包围的表达式列表,对象被直接初始化
int* p2 = new int(42); // 直接初始化为42
std::string* s2 = new std::string("Hello"); // 直接初始化为"Hello"
// new 初始化器是花括号初始化器列表,对象被列表初始化
std::vector<int>* v1 = new std::vector<int>{1, 2, 3}; // 列表初始化
- 数组类型对象初始化:
// 没有 new 初始化器,数组元素被默认初始化
int* arr1 = new int[5]; // 所有元素都被默认初始化为0
// new 初始化器是一对空括号,数组元素被值初始化
int* arr2 = new int[5](); // 所有元素都被值初始化为0
// new 初始化器是花括号初始化器列表,数组会被聚合初始化
int* arr3 = new int[3]{1, 2, 3}; // 聚合初始化,前三个元素分别初始化为1、2、3,剩余元素默认初始化为0
// (C++20起) new 初始化器是圆括号包围的非空表达式列表,数组也被聚合初始化
int* arr4 = new int[3](42); // C++20后支持,所有元素被初始化为42
请注意,在释放动态分配的对象或数组时,应使用与new
对应的delete
或delete[]
操作符。例如:
delete p1;
delete p2;
delete s1;
delete s2;
delete[] arr1;
delete[] arr2;
delete[] arr3;
delete[] arr4;
内存泄漏
内存泄漏
new 表达式所创建的对象(拥有动态存储期的对象)会持续到将 new 表达式所返回的指针用于匹配的 delete 表达式之时。如果指针的原值丢失,那么对象不可达且无法解分配:发生内存泄漏。
对指针赋值时可能发生:
int* p = new int(7); // 动态分配的 int 带值 7
p = nullptr; // 内存泄漏
或指针离开作用域:
void f()
{
int* p = new int(7);
} // 内存泄漏
或因为异常:
void f()
{
int* p = new int(7);
g(); // 可能抛出异常
delete p; // 如果没有异常则 ok
} // 如果 g() 抛出异常则内存泄漏
为了简化动态分配的对象管理,通常将 new 表达式的结果存储到智能指针中:std::auto_ptr (C++17 前)std::unique_ptr 或 std::shared_ptr (C++11 起)。这些指针保证在上述情形中执行 delete 表达式。
内存泄漏是指程序在动态分配内存后无法释放该内存,导致这部分内存无法被再次使用。如您所述,在C++中,通过
new
表达式创建的对象(拥有动态存储期的对象)必须通过对应的delete
表达式来释放,否则当指向对象的原始指针丢失、超出作用域或在异常处理过程中未正确释放时,就会发生内存泄漏。为了避免内存泄漏,可以采用智能指针来管理动态分配的对象,例如:
std::unique_ptr
(C++11起):独占所有权智能指针,确保在其生命周期结束时自动删除所管理的对象。#include <memory> void f() { std::unique_ptr<int> p(new int(7)); // 动态分配的 int 带值 7 g(); // 即使g()抛出异常,p析构时也会自动调用delete释放内存 } // 不会发生内存泄漏
std::shared_ptr
(C++11起):共享所有权智能指针,当最后一个指向同一对象的std::shared_ptr
实例销毁时,会自动删除所管理的对象。#include <memory> void f() { std::shared_ptr<int> p(new int(7)); // 动态分配的 int 带值 7 g(); // 即使g()抛出异常,当p离开作用域或引用计数变为0时,会自动调用delete释放内存 } // 不会发生内存泄漏
请注意,
std::auto_ptr
(C++17前)已被弃用,因为它具有不一致的复制行为,并且不符合标准库容器的要求。现在推荐使用std::unique_ptr
替代。
分配内存策略
在C++中,new
表达式用于动态分配内存。根据分配的对象类型不同,它会调用相应的全局或类特有替换函数:
-
非数组类型:当类型T不是数组时,
new T
会调用全局的operator new
函数,并传入sizeof(T)
作为请求的字节数。 -
数组类型:对于
new T[n]
形式的数组类型,编译器会调用全局的operator new[]
函数,传递所需总字节数(通常是sizeof(T) * n
加上可能的额外开销)。这个额外开销可能包括存储数组大小以便于delete[]
正确执行析构函数调用。 -
类特有替换函数:如果
new
表达式不在全局作用域下,即没有前缀::
,且类型T是类类型,编译器首先会在类作用域查找是否存在类特有替换版本的operator new
或operator new[]
。 -
优化策略:编译器可以在满足特定条件的情况下,省略或合并多个
new
表达式的内存分配操作。例如,当一个new
表达式分配的内存足以包含另一个new
表达式所需的空间,并且它们满足生存期、分配函数一致性和异常处理等方面的条件时,可以进行合并。 -
常量表达式求值:从C++14开始,在常量表达式求值期间,始终不调用分配函数。只有在非常量表达式上下文中,调用可替换全局分配函数的
new
表达式才能在常量表达式中求值。这意味着在编译期能够确定大小和生命周期的内存分配可以通过编译器内部优化来实现,无需实际调用分配函数。
请注意,使用new
分配内存后,需要通过对应的delete
或delete[]
表达式释放内存以避免内存泄漏。同时,自定义分配函数有助于程序对内存管理进行更精细的控制,如实现内存池、跟踪分配行为等。
分配
new 表达式通过调用适当的分配函数分配存储。如果类型 不是数组类型,那么函数名是 operator new。如果类型 是数组类型,那么函数名是 operator new[]。
如分配函数中所描述,C++ 程序可提供这些函数的全局和类特有替换函数。如果 new 表达式以 :: 运算符开始,如 ::new T 或 ::new T[n],那么忽略类特有替换函数(在全局作用域中查找函数)。否则,如果
T
是类类型,那么就会从T
的类作用域中开始查找。在调用分配函数时,new 表达式将请求的字节数作为 std::size_t 类型的第一参数传递给它,该参数对于非数组
T
恰好是 sizeof(T)。数组的分配中可能带有一个未指明的开销,且每次调用 new 的这个开销可能不同,除非选择的分配函数是标准非分配形式。new 表达式所返回的指针等于分配函数所返回的指针加上该值。许多实现使用数组开销存储数组中的对象数量,[delete] 表达式会用它进行正确数量的析构函数调用。另外,如果用 new 分配 char、unsigned char 或
std::byte
(C++17 起)的数组,那么它可能从分配函数请求额外内存,以此保证所有不大于请求数组大小的类型的对象在放入所分配的数组中时能够正确对齐。
允许将各个 new 表达式通过可替换分配函数所进行的分配予以省略或合并。在省略的情况下,存储可以由编译器提供,而无需调用分配函数(这也允许优化掉不使用的 new 表达式)。在合并的情况下且满足以下所有条件时,new 表达式 E1 所做的分配可以被扩展,以提供另一个 new 表达式 E2 的额外存储:1) E1 分配的对象的生存期严格包含 E2 所分配对象的生存期。2) E1 与 E2 会调用同一可替换全局分配函数。3) 对于抛出异常的分配函数,E1 与 E2 中的异常会首先被同一处理块捕获。注意此优化仅在使用 new 表达式,而非调用可替换分配函数的任何其他方法时允许:delete [] new int[10]; 能被优化掉,但 operator delete(operator new(10)); 不能。 (C++14 起) 在常量表达式求值期间,始终省略对分配函数的调用。只有在其他情况下调用可替换全局分配函数的 new 表达式能在常量表达式中求值。
指针空值nullptr
C++98中的指针空值
在良好的C/C++编程习惯中,在声明一个变量的同时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误。比如未初始化的指针,如果一个指针没有合法的指向,我们基本都是按如下方式对其进行初始化:
int* p1 = NULL;
int* p2 = 0;
NULL其实是一个宏,在传统的C头文件(stddef.h)中可以看到如下代码:
/* Define NULL pointer value */
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else /* __cplusplus */
#define NULL ((void *)0)
#endif /* __cplusplus */
#endif /* NULL */
可以看到,NULL可能被定义为字面常量0,也可能被定义为无类型指针(void*)的常量。但是不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,例如:
#include <iostream>
using namespace std;
void Fun(int p)
{
cout << "Fun(int)" << endl;
}
void Fun(int* p)
{
cout << "Fun(int*)" << endl;
}
int main()
{
Fun(0); //打印结果为 Fun(int)
Fun(NULL); //打印结果为 Fun(int)
Fun((int*)NULL); //打印结果为 Fun(int*)
return 0;
}
程序本意本意是想通过Fun(NULL)调用指针版本的Fun(int* p)函数,但是由于NULL被定义为0,Fun(NULL)最终调用的是Fun(int p)函数。
注:在C++98中字面常量0,既可以是一个整型数字,也可以是无类型的指针(void*)常量,但编译器默认情况下将其看成是一个整型常量,如果要将其按照指针方式来使用,必须对其进行强制转换。
C++11中的指针空值
对于C++98中的问题,C++11引入了关键字nullptr。
注意:
1、在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为关键字引入的。
2、在C++11中,sizeof(nullptr)与sizeof((void*)0)所占的字节数相同。
elete] 表达式会用它进行正确数量的析构函数调用。另外,如果用 new 分配 char、unsigned char 或 std::byte
(C++17 起)的数组,那么它可能从分配函数请求额外内存,以此保证所有不大于请求数组大小的类型的对象在放入所分配的数组中时能够正确对齐。
允许将各个 new 表达式通过可替换分配函数所进行的分配予以省略或合并。在省略的情况下,存储可以由编译器提供,而无需调用分配函数(这也允许优化掉不使用的 new 表达式)。在合并的情况下且满足以下所有条件时,new 表达式 E1 所做的分配可以被扩展,以提供另一个 new 表达式 E2 的额外存储:1) E1 分配的对象的生存期严格包含 E2 所分配对象的生存期。2) E1 与 E2 会调用同一可替换全局分配函数。3) 对于抛出异常的分配函数,E1 与 E2 中的异常会首先被同一处理块捕获。注意此优化仅在使用 new 表达式,而非调用可替换分配函数的任何其他方法时允许:delete [] new int[10]; 能被优化掉,但 operator delete(operator new(10)); 不能。 (C++14 起) 在常量表达式求值期间,始终省略对分配函数的调用。只有在其他情况下调用可替换全局分配函数的 new 表达式能在常量表达式中求值。
指针空值nullptr
C++98中的指针空值
在良好的C/C++编程习惯中,在声明一个变量的同时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误。比如未初始化的指针,如果一个指针没有合法的指向,我们基本都是按如下方式对其进行初始化:
int* p1 = NULL;
int* p2 = 0;
NULL其实是一个宏,在传统的C头文件(stddef.h)中可以看到如下代码:
/* Define NULL pointer value */
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else /* __cplusplus */
#define NULL ((void *)0)
#endif /* __cplusplus */
#endif /* NULL */
可以看到,NULL可能被定义为字面常量0,也可能被定义为无类型指针(void*)的常量。但是不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,例如:
#include <iostream>
using namespace std;
void Fun(int p)
{
cout << "Fun(int)" << endl;
}
void Fun(int* p)
{
cout << "Fun(int*)" << endl;
}
int main()
{
Fun(0); //打印结果为 Fun(int)
Fun(NULL); //打印结果为 Fun(int)
Fun((int*)NULL); //打印结果为 Fun(int*)
return 0;
}
程序本意本意是想通过Fun(NULL)调用指针版本的Fun(int* p)函数,但是由于NULL被定义为0,Fun(NULL)最终调用的是Fun(int p)函数。
注:在C++98中字面常量0,既可以是一个整型数字,也可以是无类型的指针(void*)常量,但编译器默认情况下将其看成是一个整型常量,如果要将其按照指针方式来使用,必须对其进行强制转换。
C++11中的指针空值
对于C++98中的问题,C++11引入了关键字nullptr。
注意:
1、在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为关键字引入的。
2、在C++11中,sizeof(nullptr)与sizeof((void*)0)所占的字节数相同。
3、为了提高代码的健壮性,在后序表示指针空值时建议最好使用nullptr。