C++是在C的基础之上,容纳进去了面向对象编程思想,并增加了许多有用的库,以及编程范式 等。 补充C语言语法的不足,以及C++是如何对C语言设计不合理的地方进行优化的,比如:作用域方面、IO方面、函数方面、指针方面、宏方面等。
C++关键字(C++98)
以下是 C++98 标准中的关键字列表:
asm
,do
,if
,return
,try
,continue
auto
,double
,inline
,short
,typedef
,for
bool
,dynamic_cast
,int
,signed
,typeid
,public
break
,else
,long
,sizeof
,typename
,throw
case
,enum
,mutable
,static
,union
,wchar_t
catch
,explicit
,namespace
,static_cast
,unsigned
,default
char
,export
,new
,struct
,using
,friend
class
,extern
,operator
,switch
,virtual
,register
const
,false
,private
,template
,void
,true
const_cast
,float
,protected
,this
,volatile
,while
delete
,goto
,reinterpret_cast
以下是对 C++ 中列出的关键字的简要解释和用法:
- asm: 用于嵌入汇编语言代码。
- do: 与
while
循环结合使用,执行一个语句块直到条件为假。 - if: 用于条件语句,根据条件执行不同的代码块。
- return: 从函数中返回值。
- try: 引入异常处理块,与
catch
和throw
一起使用。 - continue: 用于跳过当前循环中的剩余代码,继续下一次循环。
- auto: 用于自动类型推断。
- double: 双精度浮点数类型。
- inline: 用于内联函数。
- short: 短整数类型。
- typedef: 用于定义类型别名。
- for: 循环语句,用于重复执行一段代码块。
- bool: 布尔类型。
- dynamic_cast: 用于在运行时执行安全的类型转换。
- int: 整数类型。
- signed: 有符号整数类型。
- typeid: 返回表达式的类型信息。
- public: 类的访问修饰符,表示公有成员。
- break: 用于跳出循环或
switch
语句。 - else: 与
if
一起使用,表示条件不成立时执行的代码块。 - long: 长整数类型。
- sizeof: 返回对象或类型的大小(字节数)。
- typename: 用于指示后面的标识符为类型。
- throw: 抛出异常。
- case:
switch
语句中的一个分支。 - enum: 用于定义枚举类型。
- mutable: 成员变量可以在
const
成员函数中被修改。 - static: 静态变量或函数。
- union: 联合体,多个不同类型的成员共用同一块内存。
- wchar_t: 宽字符类型。
- catch: 捕获异常,与
try
一起使用。 - explicit: 明确的构造函数,禁止隐式类型转换。
- namespace: 命名空间。
- static_cast: 执行静态类型转换。
- unsigned: 无符号整数类型。
- default:
switch
语句中的默认分支。 - char: 字符类型。
- export: 用于导出符号。
- new: 动态分配内存。
- struct: 结构体。
- using: 引入命名空间中的标识符。
- friend: 友元函数或友元类。
- class: 定义类。
- extern: 外部链接性。
- operator: 重载运算符。
- switch: 多分支选择语句。
- virtual: 虚函数。
- register: 寄存器变量。
- const: 常量。
- false: 布尔值假。
- private: 类的访问修饰符,表示私有成员。
- template: 模板。
- void: 表示无类型或无返回值。
- true: 布尔值真。
- const_cast: 执行常量类型转换。
- float: 单精度浮点数类型。
- protected: 类的访问修饰符,表示受保护的成员。
- this: 指向当前对象的指针。
- volatile: 声明变量可能在外部修改,应该避免优化。
- while: 循环语句,当条件为真时重复执行代码块。
- delete: 释放动态分配的内存。
- goto: 跳转语句,通常应避免使用。
- reinterpret_cast: 执行底层类型转换。
命名空间
命名空间是 C++ 中用于避免命名冲突的一种机制。通过将代码放置在命名空间中,您可以将相关的变量、函数和类组织在一起,避免与其他部分代码中的同名实体产生冲突。
#include <stdio.h>
#include <stdlib.h>
int rand = 10;
// C语言没办法解决类似这样的命名冲突问题,所以C++提出了namespace来解决
int main()
{
printf("%d\n", rand);
return 0;
}
全局变量 rand
与 C 标准库中的 rand
函数名称冲窰。
在 C++ 中,可以使用命名空间来解决类似的命名冲突。
命名空间定义
// 定义命名空间
namespace MyNamespace {
// 在命名空间中定义变量、函数、类等
int x; // 变量声明
void myFunction(); // 函数声明
class MyClass {
// 类声明
};
}
// 在命名空间外定义函数的实现
void MyNamespace::myFunction() {
// 函数实现
}
使用命名空间中的实体
一般开发中是用项目名字做命名空间名。
同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。
一个命名空间就定义了一个新的作用域,命名空间中的所有内容都局限于该命名空间中。
可以通过以下方式使用命名空间中的实体:
命名空间的使用有三种方式:
加命名空间名称及作用域限定符
使用using将命名空间中某个成员引入
使用using namespace 命名空间名称 引入
-
使用全名:
MyNamespace::x = 10; MyNamespace::myFunction();
-
使用 using 声明:
using namespace MyNamespace; x = 10; myFunction();
-
使用别名:
namespace ns = MyNamespace; ns::x = 10;
嵌套命名空间
命名空间还支持嵌套,这样可以进一步组织代码:
namespace Outer {
namespace Inner {
void nestedFunction() {
// 实现
}
}
}
注意事项
- 命名空间定义可以在全局范围内,也可以在其他命名空间中。
- 命名空间中可以包含变量、函数、类等,但不允许包含对象。
- 命名空间可以被分割成几个部分,每个部分可以分散在多个文件中。
#include<iostream>
// std是C++标准库的命名空间名,C++将标准库的定义实现都放到这个命名空间中
using namespace std;
int main()
{
cout<<"Hello world!!!"<<endl;
return 0;
}
#include <iostream> // 包含输入输出流的标准库头文件
using namespace std; // 使用std命名空间
namespace MyNameSpace // 定义命名空间MyNameSpace
{
int a = 10; // 在命名空间中定义变量a并初始化为10
int Max(int a, int b); // 在命名空间中声明Max函数
}
// 在MyNameSpace命名空间外定义Max函数,实现返回较大值的功能
int MyNameSpace::Max(int a, int b)
{
return a > b ? a : b;
}
// 定义命名空间别名x,指向MyNameSpace命名空间
namespace x = MyNameSpace;
// 使用命名空间别名x引入变量a
using x::a;
int main()
{
// 修改MyNameSpace命名空间中的变量a的值为60
MyNameSpace::a = 60;
// 输出Hello world!!!
cout << "Hello world!!!" << endl;
// 输出变量a的值
cout << a << endl;
// 调用MyNameSpace命名空间中的Max函数,输出6和8中的较大值
cout << MyNameSpace::Max(6, 8);
return 0;
}
使用 cout
标准输出对象(控制台)和 cin
标准输入对象(键盘)时,必须包含 <iostream>
头文件,并按命名空间使用方法使用 std
。
cout
和cin
是全局的流对象,endl
是特殊的 C++ 符号,表示换行输出,它们都包含在包含<iostream>
头文件中。<<
是流插入运算符,>>
是流提取运算符。- 使用 C++ 输入输出更方便,不需要像
printf
/scanf
输入输出时那样需要手动控制格式。C++ 的输入输出可以自动识别变量类型。 - 实际上
cout
和cin
分别是ostream
和istream
类型的对象,>>
和<<
涉及运算符重载等。
注意:早期标准库将所有功能在全局域中实现,声明在 .h
后缀的头文件中,使用时只需包含对应头文件即可。后来将其实现在 std
命名空间下,为了和 C 头文件区分,也为了正确使用命名空间,规定 C++ 头文件不带 .h
。旧编译器(如 VC 6.0)中还支持 <iostream.h>
格式,后续编译器已不支持,因此推荐使用 <iostream>
+ std
的方式。
std命名空间的使用惯例:
std是C++标准库的命名空间,如何展开std使用更合理呢?
1. 在日常练习中,建议直接using namespace std即可,这样就很方便。
2. using namespace std展开,标准库就全部暴露出来了,如果我们定义跟库重名的类型/对 象/函数,就存在冲突问题。该问题在日常练习中很少出现,但是项目开发中代码较多、规模 大,就很容易出现。所以建议在项目开发中使用,像std::cout这样使用时指定命名空间 + using std::cout展开常用的库对象/类型等方式。
#include <iostream>
using namespace std;
int main()
{
int cout = 10;// cout,与 std::cout 发生冲突
double i = 0;
int j = 0;
std::cout << "hello world\n" ;
std::cout << cout << endl;
cin >> i>>j;
std::cout << i << " " << j;
return 0;
}
缺省参数
在 C++ 中,函数的默认参数(缺省参数)允许你在定义函数时为某些参数指定默认值。这样,在调用函数时可以省略这些参数,使得函数调用更加灵活。以下是一个简单的示例来说明如何在 C++ 中使用默认参数:
#include <iostream>
// 带有默认参数的函数声明
void greet(std::string name = "Guest");
int main() {
greet(); // 没有传递参数,将使用默认参数
greet("Alice"); // 传递参数,将覆盖默认参数
return 0;
}
// 带有默认参数的函数定义
void greet(std::string name) {
std::cout << "Hello, " << name << "!" << std::endl;
}
在上面的示例中,greet
函数带有一个默认参数 name = "Guest"
。当调用 greet
函数时,如果没有传递参数,则会使用默认参数值 "Guest"
,否则会使用传递的参数值。
默认参数的规则包括:
- 默认参数只能从右向左依次设置,也就是右侧的参数必须先有默认值,左侧的参数不能有默认值。
- 一旦为一个参数设置了默认值,其后的所有参数都必须有默认值。
使用默认参数可以简化函数调用,特别是在某些参数经常使用默认值的情况下。这样可以减少函数调用时需要传递的参数数量,提高代码的可读性和灵活性。
缺省参数概念
缺省参数是声明或定义函数时为函数的参数指定一个缺省值。在调用该函数时,如果没有指定实 参则采用该形参的缺省值,否则使用指定的实参。
缺省参数不能在函数声明和定义中同时出现,会报错就算是相同的值也是一样报错:
错误 C2572 “Func”: 重定义默认参数 : 参数 1
//a.h
void Func(int a = 10);
// a.cpp
void Func(int a = 20)
{}
// 注意:如果生命与定义位置同时出现,恰巧两个位置提供的值不同,那编译器就无法确定到底该
用那个缺省值。
缺省值必须是常量或者全局变量
C语言不支持(编译器不支持)
缺省参数分类
全缺省参数
全缺省参数:
- 全缺省参数指的是一个函数的所有参数都设置了默认值,这意味着在调用函数时可以不传递任何参数,而直接使用默认值。
- 在 C++ 中,全缺省参数可以简化函数调用,特别是当函数的大部分参数通常使用相同的值时。
半缺省参数(缺省部分参数)
半缺省参数:
- 半缺省参数指的是只有部分参数设置了默认值,未设置默认值的参数必须在函数调用时显式提供。
- 在 C++ 中,半缺省参数可以提供一些参数的默认值,同时要求调用者传递其他参数。
- 半缺省参数必须从右往左依次来给出,不能间隔着给。
#include <iostream>
using namespace std;
void Fun0(string str = "hello!")
{
cout << str << endl;
}
void Fun1(string str = "dd");
void Fun2(int a = 1, int b = 2, int c = 3);
void Fun3(int a, int b, int c = 3);//半缺省参数必须从右往左依次来给出,不能间隔着给。
int main()
{
Fun0();
Fun0("world!");
Fun1();
Fun1("hello world");
Fun2();
Fun2(3);
Fun2(5, 6, 7);
Fun2(, , 7);//err 传参必须从左往右依次传
Fun3(7, 8);
Fun3(7, 8, 9);
return 0;
}
void Fun1(string str)
{
cout << str << endl;
}
void Fun2(int a, int b, int c)
{
cout << a << " " << b << " " << c << endl;
}
void Fun3(int a, int b, int c)
{
cout << a << " " << b << " " << c << endl;
}
调用时如果要传参必须从左往右依次传参,不能空缺。
函数重载
函数重载是指在同一个作用域内,可以定义多个函数名相同但参数列表不同的函数。通过函数重载,可以根据不同的参数类型、参数个数或参数顺序来调用不同的函数。
注意:对函数返回值没有要求,仅返回值类型不同,不能重载。
错误 C2556 “void printf(int,float,double)”: 重载函数与“int printf(int,float,double)”只是在返回类型上不同
#include <iostream>
// 函数重载,根据参数个数不同进行重载
void print()
{
std::cout << "print\n";
}
void print(int num)
{
std::cout << "Printing an integer: " << num << std::endl;
}
void print(int num, int mun)
{
std::cout << "Printing double integer: " << num << "," << mun << std::endl;
}
// 函数重载,根据参数类型不同进行重载
void print(double num)
{
std::cout << "Printing a double: " << num << std::endl;
}
void print(long num)
{
std::cout << "Printing a long: " << num << std::endl;
}
void print(unsigned long num)
{
std::cout << "Printing a unsigned long: " << num << std::endl;
}
void print(std::string str)
{
std::cout << "Printing a string: " << str << std::endl;
}
//函数重载,根据参数类型顺序不同进行重载
void printf(int a, float b, double c)
{
printf("printf重载");
std::cout << a << " " << b << " " << c << std::endl;
}
void printf(float a, int b, double c)
{
printf("printf重载");
std::cout << a << " " << b << " " << c << std::endl;
}
int main()
{
print();
print(10);
print(6, 8);
print(3.14);
print(66L);
print(67ul);
print("Hello");
printf(1, 2.6f, 3.63);
printf(3.3f, 2, 6.7);
printf("printf\n");
return 0;
}
C++支持函数重载的原理-名字修饰(name Mangling)
C++ 支持函数重载的原理涉及到名字修饰(Name Mangling)。名字修饰是一种编译器在编译过程中对函数或变量名进行转换的技术,目的是为了在编译后能够唯一标识不同的函数或变量。
名字修饰的原理:
-
函数签名:
在 C++ 中,函数的签名由函数的参数列表以及参数类型和顺序组成。编译器使用函数的参数列表来生成一个唯一的函数签名,用于区分不同的函数。因此,即使函数名相同,只要参数列表不同,编译器就会将它们视为不同的函数。 -
名字修饰规则:
名字修饰规则是编译器根据函数的参数列表生成的一种标识符。这种标识符通常包含函数的参数类型、参数个数、返回类型等信息,以确保每个函数都有一个唯一的名字。 -
实现细节:
在编译过程中,编译器根据函数的参数列表和其他信息生成一个唯一的符号(名字修饰),将其用作函数在可执行文件中的标识符。这样,在链接时就能够正确地区分不同的函数。
举例说明:
假设有两个函数:
void foo(int a);
void foo(double b);
在编译过程中,这两个函数的名字会被修饰成类似以下的形式:
void foo(int)
可能会被修饰为_Z3fooi
void foo(double)
可能会被修饰为_Z3food
这样,即使函数名相同,由于名字修饰的不同,编译器能够正确地识别和区分这两个函数。
总结:
名字修饰是 C++ 支持函数重载的关键机制之一。通过名字修饰,编译器能够在编译过程中为函数生成唯一的标识符,以区分不同的函数。这种机制使得函数重载成为可能,提高了代码的灵活性和可维护性。
如果两个函数函数名和参数是一样的,返回值不同是不构成重载的,因为调用时编译器没办法区分。
extern "C"
extern "C"
是 C++ 中用来声明某个函数或变量按照 C 语言的规则进行编译的关键字。在 C++ 中,函数名会经过一种叫做名称修饰(Name Mangling)的过程,以支持函数重载、命名空间等特性。而 C 语言不支持这些功能,因此在 C++ 中调用 C 语言编写的函数时,需要告诉编译器对这些函数使用 C 语言的命名规则。
在 C++ 中,extern "C"
的作用是告诉编译器按照 C 语言的规则处理函数或变量的名称修饰,使得这些函数或变量可以和 C 语言的代码进行无缝链接。这样做的主要原因是 C++ 支持函数重载,而 C 不支持函数重载,因此需要通过 extern "C"
来告诉编译器按照 C 的规则处理函数名。
示例用法如下:
#ifdef __cplusplus
extern "C" {
#endif
void my_c_function(); // 声明一个按照 C 语言规则编译的函数
#ifdef __cplusplus
}
#endif
在上面的示例中,extern "C"
告诉编译器 my_c_function
函数应该按照 C 语言的规则命名,以便与 C 语言代码进行链接。这样,在 C++ 代码中就可以调用 C 语言编写的函数而不会出现名称修饰不一致的问题。
编译库文件
在命令行中,使用以下命令编译源文件为目标文件:
g++ -c my_library.cpp -o my_library.o
创建静态库
使用以下命令将目标文件打包成静态库文件:
ar rcs libmy_library.a my_library.o
创建动态库
使用以下命令将目标文件编译成动态库文件:
g++ -shared -o libmy_library.so my_library.o
编译可执行文件
在命令行中,使用以下命令编译主程序并链接库文件:
静态库
g++ main.cpp -L. -lmy_library -o my_program
动态库
g++ main.cpp -L. -lmy_library -o my_program -Wl,-rpath,.
g++ Test.cpp -L. -l my_library
中的 -L
和 -l
是用于指定编译器选项的标志,具体含义如下:
-L.
:这个选项告诉编译器去指定的路径(这里是当前目录.
)查找库文件。-l my_library
:这个选项告诉编译器链接名为my_library
的库文件。
因此,整个命令的意义是告诉编译器在当前目录中查找名为 my_library
的库文件并将其链接到编译目标文件 Test.cpp
中。
总结一下:
-L
选项用于指定库文件的搜索路径。-l
选项用于指定要链接的库文件,不需要包括库文件的前缀lib
和后缀名(比如.a
或.so
)。
如果您的库文件名为 libmy_library.a
,则应该使用 -lmy_library
而不是 -l libmy_library
,因为编译器会自动添加前缀 lib
和后缀名 .a
。
g++ Test.cpp -L. -l my_library -o my_program -Wl,-rpath,.
,包含了一个名为 -Wl
的编译器选项,以及 -rpath
选项的参数 .
。这个参数的含义如下:
-Wl
:这个选项允许将逗号分隔的参数传递给链接器。-rpath
:这个选项告诉链接器在运行时查找共享库时应该搜索的路径。.
:这里的.
表示当前目录,这样链接器会在当前目录中查找共享库。
因此,整个命令的意思是告诉编译器在链接时指定运行时搜索路径为当前目录,以便在运行程序时能够正确找到和加载与程序链接的共享库 my_library
。这是为了确保程序在运行时能够找到所需的共享库文件。
这种方法通常用于在程序运行时指定共享库文件的搜索路径,以便程序可以正确地加载所需的共享库。
在编译时使用 -Wl,-rpath,.
选项来指定运行时搜索路径为当前目录。
静态库(Static Library)和动态库(Dynamic Library)是两种常见的库文件形式,它们在编程中有不同的作用和使用方式。以下是它们之间的主要区别:
静态库(Static Library)
-
编译时链接:
静态库在编译时被链接到目标程序中,链接器将库的代码和数据复制到目标程序中,使得目标程序完全包含了库的代码和数据。 -
独立性:
静态库使得目标程序在运行时不需要外部的库文件支持,因为所有需要的代码和数据都已经被复制到目标程序中。 -
文件扩展名:
静态库的文件通常以.a
(Unix/Linux)或.lib
(Windows)为扩展名。 -
优点:
- 简单易用,不需要额外的库文件依赖。
- 可以确保程序在不同环境下的可移植性和稳定性。
-
缺点:
每个使用静态库的程序都会包含一份库的副本,可能导致可执行文件变得较大。
动态库(Dynamic Library)
-
运行时链接:
动态库在程序运行时被加载到内存中,并按需链接到目标程序中,因此可执行文件只包含对库的引用,而不包含库本身。 -
共享性:
多个程序可以同时共享同一个动态库的实例,减少内存占用,因为库的代码和数据只需要加载一次。 -
文件扩展名:
动态库的文件通常以.so
(Unix/Linux)或.dll
(Windows)为扩展名。 -
优点:
- 减少内存占用,提高系统性能。
- 允许库的更新,所有使用这个库的程序都能受益于更新。
-
缺点:
- 需要确保系统中有相应版本的库文件。
- 可能会引入一些运行时库依赖性问题。
选择使用静态库或动态库的考虑因素:
- 静态库适合小型项目或需要独立部署的应用程序。
- 动态库适合大型项目或需要多个程序共享库的情况。
综上所述,静态库和动态库在使用中有各自的优缺点,根据项目需求和目标选择合适的库文件形式。
引用
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空 间,它和它引用的变量共用同一块内存空间。
在 C++ 中,引用(Reference)是一个别名,可以被用来代替已经存在的变量。它提供了对变量的直接访问,因此对引用的操作实际上是在操作引用所绑定的变量。引用在声明时必须初始化,并且一旦绑定到某个变量,就不能再绑定到其他变量。
以下是关于 C++ 引用的一些重要概念:
1. 引用的声明:
在 C++ 中,引用通过在变量类型前加上 &
符号来声明。例如:
int x = 10;
int& ref = x; // ref 是 x 的引用
2. 引用的初始化:
引用在声明时必须初始化,并且一旦初始化后,就无法再改变其绑定的对象。例如:
int y = 20;
int& ref = y; // 正确,ref 绑定到 y
3. 引用作为函数参数:
引用经常用作函数的参数,可以通过引用来传递参数,这样可以避免拷贝,同时可以直接修改传入的参数值。
void increment(int& num) {
num++;
}
int main() {
int z = 30;
increment(z);
// 现在 z 的值为 31
return 0;
}
4. 引用的优点:
- 通过引用传递参数可以避免拷贝,提高性能。
- 可以通过引用直接修改传入的参数值。
- 用引用作为返回值可以实现连续赋值。
5. 引用与指针的区别:
- 引用必须在声明时初始化,且一旦初始化后不能再绑定到其他对象,而指针可以在任何时候指向不同的对象。
- 引用不可以为空,而指针可以为空。
- 引用更直观,更容易理解,指针更灵活,但也更容易出错。
引用是 C++ 中一个重要且强大的特性,能够简化代码并提高程序效率。
类型& 引用变量名(对象名) = 引用实体
注意:引用类型必须和引用实体是同种类型的
引用特性
1. 引用在定义时必须初始化
2. 一个变量可以有多个引用
3. 引用一旦引用一个实体,再不能引用其他实体
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int b = 16;
int& num = a;//引用在定义时必须初始化
int& num1 = a;//一个变量可以有多个引用
//int& num = b;//err 引用一旦引用一个实体,再不能引用其他实体
cout << a << endl;
cout << num << endl;
cout << (num = 20) << endl;
return 0;
}
常引用
在C++中,常引用(const reference)是指引用一个变量并且保证不会修改这个变量的值。通过使用常引用,可以确保在函数调用或操作中不会意外修改引用的值,同时还能避免不必要的拷贝。
常引用的声明方式如下:
const int& constRef = someVariable;
在这里,const
关键字用于声明常引用,表示引用的值是常量,不可修改。常引用通常用于函数参数中,特别是当传递大型对象时,可以提高性能并确保不会修改传入的对象。
#include <iostream>
using namespace std;
int main()
{
const int a = 10;
//int& ra = a; // 该语句编译时会出错,a为常量
const int& ra = a;
// int& b = 10; // 该语句编译时会出错,b为常量
const int& b = 20;
double d = 12.34;
//int& rd = d; // 该语句编译时会出错,类型不同
const int& rd = d;
cout << a << endl;
cout << ra << endl;
cout << b << endl;
cout << d << endl;//12.34
cout << rd << endl;//12
return 0;
}
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int& b = a;
int& c = b;
c = 20;
const int& f = c;//引用取别名时,变量访问的权限可以缩小,不能放大。
const short& e = a;//隐式转换,数据截断。
cout << a << endl;//20
return 0;
}
使用场景
1. 做参数
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
2. 做返回值
返回 int&
表示返回一个整型变量的引用。在C++中,函数可以返回引用,这允许函数修改调用者提供的变量或者返回函数内部创建的变量的引用,而不是返回变量的副本。返回引用的主要优点是可以避免不必要的复制开销,并且可以允许对原始变量进行修改。
#include <iostream>
using namespace std;
int& Count()
{
static int n = 0;
n++;
return n;
}
int main()
{
cout << Count() << endl;
cout << Count() << endl;
cout << Count() << endl;
Count() = 20;
cout << Count() << endl;
cout << Count() << endl;
return 0;
}
注意:如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。
#include <iostream>
using namespace std;
int& Add(int a, int b)
{
static int c = a + b;//只执行一次
return c;
}
int main()
{
Add(3, 4);//7
int& ret = Add(1, 2);//7
cout << Add(5, 6) << endl;//7
return 0;
}
在C++中,当一个局部静态变量被声明为static
时,它只会在程序的生命周期内初始化一次。这意味着当程序第一次执行到初始化该静态变量的语句时,变量会被初始化,而后续的函数调用不会再重新初始化这个静态变量,而是保持其上一次的值。
static int c = a + b;
这行代码会在第一次调用Add
函数时执行,计算a + b
的值并将结果赋给静态变量c
。在后续的函数调用中,不会再重新执行这行代码,而是继续使用上一次计算的结果,因为静态变量在函数调用之间保持其值。
#include <iostream>
using namespace std;
int& Add(int a, int b)
{
static int c = 0;
c = a + b;
return c;
}
int main()
{
Add(3, 4);//7
int& ret = Add(1, 2);//3
cout << Add(5, 6) << endl;//11
return 0;
}
传值、传引用效率比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直 接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效 率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
传值(Pass by Value)的优点和缺点:
-
优点:
- 简单直观:传递值是最直接的方式,易于理解和实现。
- 安全性:避免了因为函数修改参数值而导致不可预料的结果。
-
缺点:
- 拷贝开销:对于大型对象,传值会涉及到对象的拷贝,会增加内存和时间开销。
- 不适用于大对象:对于大对象,传值会消耗更多的内存和时间。
- 频繁的拷贝操作可能会影响性能。
传引用(Pass by Reference)的优点和缺点:
-
优点:
- 效率高:传引用不会涉及到对象的拷贝,避免了拷贝的开销。
- 适用于大对象:传引用适用于大型对象,避免了拷贝整个对象的开销。
- 可以修改调用者的变量。
-
缺点:
- 安全性:传引用可能导致副作用,函数对参数的修改会影响到调用者的变量。
- 对象的生命周期:需要确保引用指向的对象在函数调用期间有效,避免出现悬垂引用。
效率比较:
- 对于小型内置类型:传值和传引用的性能差异通常可以忽略不计。
- 对于大型对象:传引用通常比传值更高效,因为避免了拷贝整个对象的开销。
- 如果不需要修改参数:传值更安全,但传引用更高效。
#include <iostream>
#include <ctime>
using namespace std;
struct A { int a[100000]; };
void TestFunc1(A a) {}
void TestFunc2(A& a) {}
void TestRefAndValue()
{
struct A a;
// 以值作为函数参数
clock_t begin1 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc1(a);
clock_t end1 = clock();
// 以引用作为函数参数
clock_t begin2 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc2(a);
clock_t end2 = clock();
// 计算两个函数运行结束后的时间(以毫秒为单位)
double time1 = double(end1 - begin1) * 1000.0 / CLOCKS_PER_SEC;
double time2 = double(end2 - begin2) * 1000.0 / CLOCKS_PER_SEC;
cout << "TestFunc1(A)-time: " << time1 << " milliseconds" << endl;
cout << "TestFunc2(A&)-time: " << time2 << " milliseconds" << endl;
}
int main()
{
TestRefAndValue();
return 0;
}
值和引用的作为返回值类型的性能比较
#include <iostream>
#include <ctime>
using namespace std;
struct A { int a[10000]; };
A a;
// 值返回
A TestFunc1() { return a; }
// 引用返回
A& TestFunc2() { return a; }
void TestReturnByRefOrValue()
{
// 以值作为函数的返回值类型
clock_t begin1 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc1();
clock_t end1 = clock();
// 以引用作为函数的返回值类型
clock_t begin2 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc2();
clock_t end2 = clock();
// 计算两个函数运算完成之后的时间(以毫秒为单位)
double time1 = double(end1 - begin1) * 1000.0 / CLOCKS_PER_SEC;
double time2 = double(end2 - begin2) * 1000.0 / CLOCKS_PER_SEC;
cout << "TestFunc1 time: " << time1 << " milliseconds" << endl;
cout << "TestFunc2 time: " << time2 << " milliseconds" << endl;
}
int main()
{
TestReturnByRefOrValue();
return 0;
}
引用和指针的区别
在语法概念上引用就是一个别名,没有独立空间与其引用实体共用同一块空间。
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int& b = a;
cout << "&a = " << &a << endl;
cout << "&b = " << &b << endl;
return 0;
}
在底层实现上实际是有空间的,因为引用是按照指针方式来实现的。
引用和指针的不同点:
1. 引用概念上定义一个变量的别名,指针存储一个变量地址。
2. 引用在定义时必须初始化,指针没有要求。
3. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何 一个同类型实体。
4. 没有NULL引用,但有NULL指针 。
5. 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32 位平台下占4个字节) 。
6. 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小。
7. 有多级指针,但是没有多级引用。
8. 访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
9. 引用比指针使用起来相对更安全。
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int& b = a;
//short& c = a;//err 在引用中 类型不同会发生隐式类型转换 而在c++中隐式类型转换会产生临时变量 而临时变量具有常属性 所以要加const修饰才行
const short& c = a;
return 0;
}
#include <iostream>
using namespace std;
int& Add(int a, int b)
{
int c = a + b;//局部变量调用完函数就销毁
return c;//返回的一个引用不安全 因为指向的空间被释放 谁都可以修改这个空间内容
}
void Change(int x)//因为该函数声明了一个跟Add函数一样大小的变量,通过该变量 可以修改 Add返回值的内容
{
int p = x;
}
int main()
{
int& ret = Add(1, 2);
Add(2, 4);
Change(88);
cout << ret << endl;//88
return 0;
}
内联函数
内联函数是 C++ 中的一个概念,用于告诉编译器在每个调用点上直接展开函数代码,而不是通过函数调用的方式执行。这样可以减少函数调用的开销,提高程序的性能。
在 C++ 中,使用 inline
关键字可以声明内联函数。通常,内联函数适用于短小的函数,因为每个调用点上都会将函数的代码复制一份,如果函数过于庞大,内联会导致代码膨胀,反而可能降低性能。
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
以下是一个简单的示例,展示如何定义和使用内联函数:
#include <iostream>
// 定义一个内联函数
inline int Add(int a, int b) {
return a + b;
}
int main() {
int x = 5, y = 3;
int result = Add(x, y); // 调用内联函数
std::cout << "Result: " << result << std::endl;
return 0;
}
在这个示例中,Add
函数被声明为内联函数。当编译器看到 Add(x, y)
的调用时,它会直接将 Add
函数的代码插入到调用点,而不是生成一个函数调用。这样可以避免函数调用的开销。
需要注意的是,编译器不一定会遵循 inline
关键字的请求,它会根据实际情况来决定是否内联函数。通常,编译器会优化那些简单、频繁调用的函数。
inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,
缺陷:可能会使目标文件变大,
优势:少了调用开销,提高程序运行效率。
inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,
一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不 是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。
inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。
C++有哪些技术替代宏?
1. 常量定义换用const
#define N 10 替换为 const int N = 10;
2. 短小函数定义换用内联函数
auto关键字(C++11)
auto
关键字是 C++11 引入的一个特性,用于在编译时自动推导变量的类型。使用 auto
关键字可以让编译器根据变量的初始化表达式推断出变量的类型,从而简化代码书写,减少不必要的重复类型声明,提高代码的可读性和可维护性。
变量声明:
auto x = 10; // 推导 x 的类型为 int
auto str = "Hello"; // 推导 str 的类型为 const char*
auto d = 3.14; // 推导 d 的类型为 double
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量。
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一 个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
使用auto定义变量时必须对其进行初始化以推导变量类型。
【注意】 使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto 的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
auto的使用细则
1. auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&
int main()
{
int x = 10;
auto a = &x;
auto* b = &x;
auto& c = x;
cout << typeid(a).name() << endl;
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
*a = 20;
*b = 30;
c = 40;
return 0;
}
2. 在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译 器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
3. auto不能推导的场景
1. auto不能作为函数的参数
// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导
void TestAuto(auto a)
{}
2. auto不能直接用来声明数组
void TestAuto()
{
int a[] = {1,2,3};
auto b[] = {4,5,6};//err
}
3. 为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法
4. auto在实际中最常见的优势用法就是跟C++11提供的新式for循环,还有 lambda表达式等进行配合使用。
基于范围的for循环(C++11)
基于范围的 for 循环(Range-based for loop)是 C++11 引入的一种简洁的循环语法,用于遍历容器中的元素,取代传统的使用迭代器的循环方式。这种循环语法使得代码更加简洁,易读,提高了代码的可维护性。
基本语法结构如下:
for (auto element : container) {
// 使用 element 进行操作
}
在基于范围的 for 循环中,container
是一个可遍历的容器(如数组、向量、列表等),element
是容器中的元素,循环会依次遍历容器中的每个元素,并执行循环体中的操作。
for循环后的括号由冒号“ :”分为两部分:第一部分是范 围内用于迭代的变量,第二部分则表示被迭代的范围。
注意:与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环。
#include <iostream>
using namespace std;
int main()
{
int arr[] = {1,2,3,4,5,6};
for (auto& e : arr)
{
cout << e << " ";
e *= 2;
}
cout << endl;
for (auto i : arr)
{
cout << i << " ";
}
cout << endl;
return 0;
}
范围for的使用条件
1. for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供 begin和end的方法,begin和end就是for循环迭代的范围。
2. 迭代的对象要实现++和==的操作。
指针空值nullptr(C++11)
在 C++11 中引入了 nullptr
关键字,用于表示空指针。nullptr
是一种空指针常量,用于取代传统的 NULL
或 0
,以提高代码的清晰度和类型安全性。以下是关于 nullptr
的一些重要信息:
-
类型安全性:
nullptr
是一个空指针常量,具有自己的特定类型std::nullptr_t
,可以隐式转换为任意指针类型,但不会隐式转换为整数类型,避免了空指针与整数之间的混淆。 -
替代
在 C++11 中,推荐使用NULL
和0
:nullptr
来代替传统的NULL
或0
,因为nullptr
具有明确的空指针类型,提高了代码的可读性和类型安全性。 -
避免重载歧义:
使用nullptr
可以避免函数重载产生的二义性,因为nullptr
的类型是确定的,可以与其他指针类型区分开来。 -
安全性:
在许多情况下,传统的NULL
或0
可能会导致编译器将其解释为整数,而nullptr
明确表示空指针,提高了代码的安全性。 -
条件判断:
使用nullptr
进行空指针检查更加明确和类型安全,例如if (ptr == nullptr)
。 -
函数重载:
使用nullptr
可以帮助编译器正确地选择函数重载,避免与整数重载产生歧义。 -
初始化:
在 C++11 中,推荐使用nullptr
进行指针的显式初始化,而不是使用NULL
或0
。
#include <iostream>
void foo(int* ptr) {
if (ptr == nullptr) {
std::cout << "Pointer is null" << std::endl;
} else {
std::cout << "Pointer is not null" << std::endl;
}
}
int main() {
int* ptr = nullptr; // 使用 nullptr 初始化指针
foo(ptr);
return 0;
}
C++98中的指针空值
在良好的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*)的常量。不论采取何 种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:
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。
在 C++98 标准中,通常使用 NULL
或者 0
来表示空指针。这两个值在 C++98 中被广泛用于表示指针不指向任何有效对象或地址的情况。然而,这种表示空指针的方式在 C++11 中引入了更安全和明确的 nullptr
。
以下是关于在 C++98 中使用 NULL
和 0
表示空指针的一些要点:
-
NULL
宏:NULL
是一个宏,在 C++ 中通常被定义为(void*)0
,表示空指针。虽然在 C++ 中使用NULL
是合法的,但在 C++ 中也可以发现一些问题,因为它实际上是一个整数零的替代。
-
整数
0
:- 在 C++ 中,整数
0
也可以用来表示空指针,因为 C++ 允许在指针上进行零值初始化。
- 在 C++ 中,整数
-
类型模糊性:
- 使用
NULL
或0
可能导致类型模糊性,因为它们实际上只是整数零,可能会造成与整数类型的混淆。
- 使用
-
潜在问题:
- 使用
NULL
或0
可能会导致一些潜在的问题,例如在函数重载时可能会引起二义性。
- 使用
注意:
1. 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
2. 在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
3. 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。
面向过程和面向对象
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完 成。
类的引入
C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数。
如用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现, 会发现struct中也可以定义函数。
类的定义
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分
号不能省略。
类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者
成员函数。
类的两种定义方式:
1. 声明和定义全部放在类体中,需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
2. 类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名::
类的访问限定符及封装
访问限定符
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选 择性的将其接口提供给外部的用户使用。
【访问限定符说明】
1. public修饰的成员在类外可以直接被访问
2. protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
3. 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
4. 如果后面没有访问限定符,作用域就到 } 即类结束。
5. class的默认访问权限为private,struct为public(因为struct要兼容C)
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来定义类。和class定义类是一样的,区别是struct定义的类默认访问权限是public,class定义的类默认访问权限是private。
封装
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来 和对象进行交互。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来 隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域。
class Person
{
public:
void PrintPersonInfo();
private:
char _name[20];
char _gender[3];
int _age;
};
// 这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo()
{
cout << _name << " "<< _gender << " " << _age << endl;
}
类的实例化
在 C++ 中,类的实例化是指创建类的对象(也称为类的实例)。
类是一种自定义的数据类型,用于描述对象的属性和行为。它描述了对象应该具有哪些属性和行为,但并没有分配实际的内存空间来存储对象的数据。
当我们定义一个类时,我们实际上定义了一个新的数据类型,该类型包含了数据成员(属性)和成员函数(行为)的描述。但是,只有当我们创建类的对象(实例化类)时,才会在内存中分配空间来存储对象的实际数据。
一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间。
#include <iostream>
#include <string>
// 类的定义
class Person {
public:
std::string name;
int age;
void displayInfo() {
std::cout << "Name: " << name << std::endl;
std::cout << "Age: " << age << std::endl;
}
};
int main() {
// 定义类,但并没有实例化
Person person1; // 此时并没有分配内存空间来存储对象的数据
// 实例化类,分配内存空间
Person person2; // 在这里实例化类,分配内存空间来存储对象的数据
person2.name = "Bob";
person2.age = 25;
person2.displayInfo();
return 0;
}
计算类对象的大小
在 C++ 中,一个类的大小实际上是该类中所有成员变量的大小之和,包括可能由编译器添加的填充字节以进行内存对齐。对于空类来说,编译器会为其分配一个字节的大小,以确保每个类对象都有一个唯一的地址。
#include <iostream>
using namespace std;
class A1 {
public:
void f1() {}
private:
int _a;
};
class A2 {
public:
void f2() {}
};
class A3 {};
void printSizeOfClasses() {
cout << "Size of class A1: " << sizeof(A1) << " bytes" << endl;
cout << "Size of class A2: " << sizeof(A2) << " bytes" << endl;
cout << "Size of class A3: " << sizeof(A3) << " bytes" << endl;
}
int main() {
printSizeOfClasses();
return 0;
}
结构体内存对齐规则
1. 第一个成员在与结构体偏移量为0的地址处。
2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8
3. 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整
体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
#include <iostream>
using namespace std;
// 定义一个结构体
struct MyStruct {
char a; // 1字节
int b; // 4字节
double c; // 8字节
};
int main() {
// 计算最大对齐数
size_t max_alignment = alignof(char);
max_alignment = std::max(max_alignment, alignof(int));
max_alignment = std::max(max_alignment, alignof(double));
// 计算结构体的大小
size_t struct_size = sizeof(MyStruct);
size_t remainder = struct_size % max_alignment;
if (remainder != 0) {
struct_size += max_alignment - remainder;
}
cout << "Max alignment in MyStruct: " << max_alignment << " bytes" << endl;
cout << "Size of MyStruct: " << struct_size << " bytes" << endl;
return 0;
}
this指针
在 C++ 中,this
指针是一个指向当前对象的指针,它是每个非静态成员函数的隐含参数。当对象调用其成员函数时,编译器会自动将对象的地址传递给该函数的 this
指针,以便函数能够访问和操作该对象的成员变量和成员函数。
C++编译器给每个“非静态的成员函数“增加了一个隐藏 的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量” 的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
1. this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
2. 只能在“成员函数”的内部使用
3. this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给
this形参。所以对象中不存储this指针。
4. this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传
递,不需要用户传递
this指针存在栈区
在 C++ 中,this
指针通常不应该为空。this
指针是一个指向当前对象的指针,它在成员函数中使用,用于访问对象的成员变量和成员函数。如果在一个非静态成员函数中访问 this
指针,而该函数被调用时当前对象不存在(即指针为空),则可能导致未定义的行为。因此,最好确保 this
指针不为空。
在 C++ 中,每个非静态成员函数都有一个隐含的指向当前对象的指针,这个指针被称为 this
指针。this
指针指向调用该成员函数的对象本身,允许在成员函数中访问对象的成员变量和方法。
#include <iostream>
using namespace std;
class Date
{
private:
int _year;
int _month;
int _day;
public:
void ShowDate()//void ShowDate(Date& this)
{
cout << _year << "-" << _month << "-" << _day << endl;
//cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
void SetDate(int year, int month, int day)// void SetDate(Date& this, int year, int month, int day)
{
_year = year;//this->_year = year;
_month = month;//this->_month = month;
_day = day;//this->_day = day;
}
};
int main()
{
Date d;
d.SetDate(2000, 9, 6);// d.SetDate(&d,2000, 9, 6);
d.ShowDate();// d.ShowDate(&d);
}
类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。 空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
在 C++ 中,对于每个类,如果没有显式定义以下六个成员函数,编译器会自动生成它们。这些成员函数通常被称为默认成员函数。这六个默认成员函数包括:
-
默认构造函数 (Default Constructor): 如果没有显式定义构造函数,编译器会生成一个无参构造函数。这个构造函数用于创建对象时进行初始化。
-
析构函数 (Destructor): 如果没有显式定义析构函数,编译器会生成一个默认的析构函数。析构函数用于对象销毁时进行资源释放等清理操作。
-
拷贝构造函数 (Copy Constructor): 如果没有显式定义拷贝构造函数,编译器会生成一个默认的拷贝构造函数。这个函数用于在对象进行拷贝初始化时调用。
-
拷贝赋值运算符 (Copy Assignment Operator): 如果没有显式定义拷贝赋值运算符,编译器会生成一个默认的拷贝赋值运算符。这个函数用于在对象进行赋值操作时调用。
-
移动构造函数 (Move Constructor): 如果没有显式定义移动构造函数,编译器会生成一个默认的移动构造函数。这个函数用于在对象进行移动初始化时调用。
-
移动赋值运算符 (Move Assignment Operator): 如果没有显式定义移动赋值运算符,编译器会生成一个默认的移动赋值运算符。这个函数用于在对象进行移动赋值操作时调用。
构造函数
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
- 构造函数与类同名,没有返回类型(甚至没有 void)。
- 默认构造函数没有参数,用于在创建对象时初始化对象的成员变量。
- 带参数的构造函数带有参数,用于根据传入的参数初始化对象的成员变量。
- 拷贝构造函数用于通过复制另一个对象来初始化对象。
- 析构函数(析构器)在对象销毁时自动调用,用于释放资源或执行清理操作。
- 在主程序中,通过调用构造函数来创建对象,并使用这些对象。
构造函数在对象创建时被调用,用于初始化对象的状态。构造函数的特点在于,它们在对象创建时自动调用,并可以进行必要的初始化操作。
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任 务并不是开空间创建对象,而是初始化对象。
其特征如下:
1. 函数名与类名相同。
2. 无返回值类型。
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
注意:C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都称为默认构造函数。并且默认构造函数只能有一个。
#include <iostream>
using namespace std;
class Time
{
public:
// 默认构造函数,初始化时间为 10:29:36
Time()
{
_hour = 10;
_minute = 29;
_second = 36;
}
// 显示时间
void Show()
{
cout << _hour << ":" << _minute << ":" << _second << endl;
}
private:
int _hour;
int _minute;
int _second;
};
int main()
{
// 创建 Time 类对象 d
Time d;
// 显示时间
d.Show();
return 0;
}
析构函数
析构函数(Destructor)是一种特殊类型的成员函数,它在对象生命周期结束时被调用,用于释放对象所占用的资源。在C++中,析构函数的名称与类名相同,但在前面加上一个波浪号(~)作为前缀。析构函数没有返回类型,也不接受任何参数。
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由 编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
析构函数是特殊的成员函数,其特征如下:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构
函数不能重载
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
#include <iostream>
using namespace std;
class Time
{
public:
// 带参全缺省构造函数
Time(int h = 0, int m = 1, int s = 1)
{
_hour = h;
_minute = m;
_second = s;
cout << "Default constructor called." << endl;
}
// 析构函数,用于释放资源或执行清理操作
~Time()
{
cout << "Destructor called." << endl;
}
// 显示时间
void Show()
{
cout << _hour << ":" << _minute << ":" << _second << endl;
}
private:
int _hour;
int _minute;
int _second;
};
int main()
{
// 创建 Time 类对象 d
Time d1;
// 显示时间
d1.Show();
Time d2(11,22,33);
d2.Show();
return 0;
}
#include <iostream>
using namespace std;
class Time {
public:
Time(int h = 0, int m = 0, int s = 0) : hour(h), minute(m), second(s) {
cout << "构造函数被调用" << endl;
}
~Time() {
cout << "析构函数被调用" << endl;
}
void 显示时间() {
cout << hour << ":" << minute << ":" << second << endl;
}
private:
int hour;
int minute;
int second;
};
int main() {
Time t1(10, 20, 30);
t1.显示时间();
Time t2(5, 15, 25);
t2.显示时间();
cout << "离开内部作用域" << endl;
return 0;
}
#include <iostream>
using namespace std;
class Stack
{
private:
int* _a;
int _size;
int _capacity;
public:
Stack(int n = 10)
{
cout << "构造函数" << n << endl;
_a = (int*)malloc(sizeof(int) * n);
cout << "malloc:" << _a << endl;
_size = 0;
_capacity = 0;
}
~Stack()
{
if (_a)
{
free(_a);
cout << "free(_a):" << _a << endl;
_a = nullptr;
_size = _capacity = 0;
}
}
};
int main()
{
Stack s1;
Stack s2(11);//栈是后进先出 后实现的对象先调用析构函数 是从后依次向前调用
//所以先析构的是s2然后是s1
return 0;
}
/*
打印结果:
构造函数10
malloc:00000260A96A53E0
构造函数11
malloc:00000260A96A5450
free(_a):00000260A96A5450
free(_a):00000260A96A53E0
*/
拷贝构造函数
拷贝构造函数是构造函数的一种特殊形式,用于创建一个类对象的副本。以下是一些关于拷贝构造函数的重要点:
-
重载形式:
- 拷贝构造函数是构造函数的一种重载形式,用于按照已存在的对象创建一个新的对象副本。
-
参数和调用:
- 拷贝构造函数的参数只有一个,且必须是类类型对象的引用。
- 如果使用传值方式传递参数,会导致无限递归调用,因此编译器会报错。
-
默认拷贝构造函数:
- 如果未显式定义拷贝构造函数,编译器会自动生成默认的拷贝构造函数。
- 默认拷贝构造函数执行浅拷贝(即按字节拷贝),对于用户自定义类型,会调用其拷贝构造函数完成拷贝。
-
资源管理:
- 当类涉及到资源管理时,特别是动态内存分配,拷贝构造函数的编写变得至关重要。
- 在涉及资源管理的情况下,如果不编写正确的拷贝构造函数,可能会导致浅拷贝问题,造成资源释放错误或重复释放。
拷贝构造函数典型调用场景:
使用已存在对象创建新对象
函数参数类型为类类型对象
函数返回值类型为类类型对象
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用 尽量使用引用。
#include <iostream>
using namespace std;
class Time
{
public:
Time(int h = 0, int m = 0, int s = 0)//构造函数
{
cout << "Time()" << endl;
_hour = h;
_minute = m;
_second = s;
cout << _hour << ":" << _minute << ":" << _second << endl;
}
Time(const Time* t)//拷贝构造函数
{
_hour = t->_hour;
_minute = t->_minute;
_second = t->_second;
}
~Time()//析构函数
{
cout << "~Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
void TimeShow()
{
cout << _hour << ":" << _minute << ":" << _second << endl;
}
private:
int _hour;
int _minute;
int _second;
};
int main()
{
Time t(23,23,23);
Time t1(&t);
Time t2 = t1;
t.TimeShow();
t1.TimeShow();
t2.TimeShow();
return 0;
}
赋值运算符重载
运算符重载
运算符重载(Operator overloading)是一种编程语言特性,允许在类中重新定义运算符的含义,使其适用于自定义类型的对象。通过运算符重载,可以更直观地使用自定义类型的对象,使代码更易读且更符合直觉。
在许多面向对象的编程语言中,如C++和Python,都支持运算符重载。通过重载运算符,可以定义自定义类型的对象之间的加法、减法、乘法等操作,使其行为类似于内置类型。
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其
返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意:
不能通过连接其他符号来创建新的操作符:比如operator@
重载操作符必须有一个类类型参数
用于内置类型的运算符,其含义不能改变,例如:内置的整型int,不能改变其含义
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐
藏的this
.* :: sizeof ?: . 注意以上5个运算符不能重载。
#include <iostream>
using namespace std;
class Time
{
public:
Time(int h = 0, int m = 0, int s = 0)//构造函数
{
cout << "Time()" << endl;
_hour = h;
_minute = m;
_second = s;
cout << _hour << ":" << _minute << ":" << _second << endl;
}
Time(const Time& t)//拷贝构造函数
{
_hour = t._hour;//this->_hour = t._hour;
_minute = t._minute;//this->_minute = t._minute;
_second = t._second;//this->_second = t._second;
}
~Time()//析构函数
{
cout << "~Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
void TimeShow()// void TimeShow(Time* this)
{
cout << _hour << ":" << _minute << ":" << _second << endl;
//cout << this->_hour << ":" << this->_minute << ":" << this->_second << endl;
}
int setime(int hh, int mm, int ss)
{
_hour = hh;
_minute = mm;
_second = ss;
}
//因为隐藏this指针特性 bool operator==(const Time& t1, const Time& t2)是错误的因为实际编译器会转换成bool operator==(Time* this,const Time& t1, const Time& t2)
bool operator == (const Time& t2)// bool operator == (Time* this,const Time& t2)
{
return _hour == t2._hour && _minute == t2._minute && _second == t2._second;
}
bool operator > (const Time& t2)// bool operator == (Time* this,const Time& t2)
{
return _hour > t2._hour || _hour == t2._hour && _minute > t2._minute || _hour == t2._hour && _minute == t2._minute && _second > t2._second;
}
private:
int _hour;
int _minute;
int _second;
};
//t1==t2; 相当于 operator == (t1,t2);
/*bool operator==(const Time& t1, const Time& t2)//这个必须是成员变量为公有才能访问
{
return t1._hour == t2._hour && t1._minute == t2._minute && t1._second == t2._second;
}*/
int main()
{
Time t(23,23,23);
Time t1(t);
Time t2 = t1;
t.TimeShow();//实际编译器是转换为 t.TimeShow(&t);
t1.TimeShow();//实际编译器是转换为 t1.TimeShow(&t1);
t2.TimeShow();//实际编译器是转换为 t2.TimeShow(&t2);
cout << (t1 == t2)<<endl;
cout << t1.operator==(t2) << endl;//t.operator==(&t1,t2);
t1.setime(20, 23, 26);
cout << (t1 > t2) << endl;
return 0;
}
#include <iostream>
using namespace std;
class Date
{
private:
int _year;
int _month;
int _day;
public:
int GetMonthDay(int year, int month)const//获取月的天数
{
static int monthDays[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0))//闰年判断:四年一润,百年不润。或者四百年一润。
{
return 29;//闰年2月29天
}
return monthDays[month];
}
Date(int year = 0, int month = 1, int day = 1)
{
if (year >= 0 && month >= 1 && month <= 12 && day >= 1 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "非法日期" << endl;
}
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void print()const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
bool operator > (const Date& d)const
{
return _year > d._year || _year == d._year && _month > d._month || _year == d._year && _month == d._month && _day > d._day;
}
bool operator == (const Date& d)const
{
return _year == d._year && _month == d._month && _day == d._day;
}
bool operator < (const Date& d)const
{
return !(*this > d || *this == d);
}
bool operator >= (const Date& d)const
{
return !(*this < d);
}
bool operator <= (const Date& d)const
{
return !(*this > d);
}
Date operator + (int day)
{
Date ret(*this);//用d1拷贝构造一个ret 等价Date ret = *this;
if (day < 0)
{
day = -day;
return ret - day;
}
ret._day += day;
while (ret._day > GetMonthDay(ret._year, ret._month))
{
ret._day -= GetMonthDay(ret._year, ret._month);
ret._month++;
if (ret._month == 13)
{
ret._year++;
ret._month = 1;
}
}
return ret;
}
Date& operator += (int day)
{
if (day < 0)
{
return *this -= -day;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
Date operator - (int day)const
{
Date ret(*this); // 拷贝构造一个新的对象
if (day < 0)
{
day = -day;
return ret + day;
}
ret._day -= day;
while (ret._day < 1)
{
ret._month--;
if (ret._month < 1)
{
ret._year--;
ret._month = 12;
}
ret._day += GetMonthDay(ret._year, ret._month);
}
return ret;
}
Date& operator -= (int day)
{
if (day < 0)
{
return *this += -day;
}
Date ret(*this); // 拷贝构造一个新的对象
_day -= day;
while (_day < 1)
{
_month--;
if (_month < 1)
{
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
// 前置++
Date& operator++() {
_day++;
if (_day > GetMonthDay(_year, _month)) {
_day = 1;
_month++;
if (_month > 12) {
_month = 1;
_year++;
}
}
return *this;
}
// 后置++
Date operator++(int) {
Date temp = *this;
++(*this); // 调用前置++
return temp;
}
// 前置--
Date& operator--() {
_day--;
if (_day < 1) {
_month--;
if (_month < 1) {
_month = 12;
_year--;
}
_day = GetMonthDay(_year, _month);
}
return *this;
}
// 后置--
Date operator--(int) {
Date temp = *this;
--(*this); // 调用前置--
return temp;
}
int operator - (const Date& date)const;
};
// 计算两个日期之间的天数差
int Date::operator - (const Date& date)const
{
int day = 0;
int flag = 1;
Date ret(*this);
Date ret2(date);
if (ret._year < date._year)
{
Date tmp(ret);
ret = ret2;
ret2 = tmp;
flag = -1;
}
while (ret._year > ret2._year)
{
if ((ret._year % 4 == 0 && ret._year % 100 != 0) || ret._year % 400 == 0)
{
day += 366;
}
else
{
day += 365;
}
ret._year--;
}
while (ret._month > ret2._month)
{
day += GetMonthDay(ret._year, ret._month);
ret._month--;
}
while (ret._day > ret2._day)
{
day++;
ret._day--;
}
return day * flag;
}
int main()
{
Date d1;
d1.print();
Date d2(2020, 9, 9);
d2.print();
Date d3(d2);
// 声明一个成员函数指针,指向 Date 类的 print 成员函数
void(Date:: * p)()const = &Date::print;
// 通过对象 d2 调用成员函数指针指向的成员函数
(d2.*p)();
d3.print();
cout << (d1 >= d2) << endl;
(d2 + 365).print();
(d2 += 30).print();
(d2 += 30).print();
(d2 - 30).print();
(d2 -= 30).print();
(d2 -= 30).print();
Date d4(2021, 10, 9);
d4.print();
cout << (d4 - d2) << endl;
cout << (d2 - d4) << endl;
(d4 + -10).print();
return 0;
}
赋值运算符重载
在 C++ 中,赋值运算符只能作为类的成员函数进行重载,而不能作为全局函数进行重载。这是因为赋值运算符 =
是一个特殊的运算符,用于给对象赋值,因此只能在类的作用域内进行重载。
赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现 一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注 意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符 重载完成赋值。
参数类型:const T&,传递引用可以提高传参效率
返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
在C++中,赋值运算符 =
是用于给对象赋值的特殊运算符。通过重载赋值运算符,你可以自定义类对象之间的赋值行为。以下是对赋值运算符重载的详细解释:
1. 为什么要重载赋值运算符?
赋值运算符的重载允许你控制类对象之间的赋值操作。默认情况下,C++ 提供的赋值运算符执行浅拷贝,即简单地复制对象的数据成员。但对于包含动态内存分配或其他特殊资源管理的类,通常需要实现自定义的赋值操作,以确保资源正确释放和对象的行为符合预期。
2. 重载赋值运算符的语法
在类中重载赋值运算符的一般语法如下:
class MyClass {
public:
MyClass& operator=(const MyClass& other) {
if (this != &other) {
// 处理赋值操作
}
return *this;
}
};
3. 重载赋值运算符的特点
- 赋值运算符总是以成员函数的形式重载,其返回类型通常为类的引用,以支持连续赋值。
- 参数通常是常量引用,以避免不必要的对象复制和提高效率。
- 在重载函数中,通常需要检查自赋值情况,即确保不是将对象赋值给自身。
4. 自赋值检查
在重载的赋值运算符中,需要注意避免自赋值情况。自赋值可能导致资源泄漏或未定义的行为。一种简单的检查方法是在赋值操作之前检查对象的地址是否相同:
MyClass& operator=(const MyClass& other) {
if (this != &other) {
// 处理赋值操作
}
return *this;
}
5. 支持连续赋值
返回值为赋值运算符重载函数的引用,可以支持连续赋值操作。例如:
MyClass obj1, obj2, obj3;
obj1 = obj2 = obj3;
6. 总结
通过重载赋值运算符,你可以控制类对象之间的赋值行为,确保对象在赋值操作中的状态正确和资源管理良好。在重载赋值运算符时,应当注意自赋值检查、返回引用以支持连续赋值等细节。
前置++和后置++重载
C++编译器能够判断是调用 operator++(int)
还是 operator++()
的原因在于函数调用的语法规则和重载函数的匹配规则。在区分前置和后置自增运算符时,编译器依赖于以下规则:
-
后置自增运算符参数规则:
- 后置自增运算符
operator++(int)
会接受一个额外的int
参数(虽然在实际调用时无需传递实际值)。 - 这个额外参数的存在使得编译器能够区分后置自增运算符和前置自增运算符。
- 后置自增运算符
-
函数调用操作符:
- 在后置自增运算符的调用中,编译器会注意到
++
符号在变量名之后,即var++
。 - 根据语法规则,后置自增运算符
operator++(int)
需要在函数名后面跟随一个空的参数列表(int)
。
- 在后置自增运算符的调用中,编译器会注意到
基于以上规则,编译器在编译阶段能够正确地识别后置自增运算符的调用,并调用相应的 operator++(int)
函数。
在编译时,编译器会根据函数的签名和参数列表来确定调用哪个重载函数。在这种情况下,编译器能够根据函数参数的存在与否来区分调用前置自增运算符还是后置自增运算符的重载函数。
// 前置++:返回+1之后的结果
// 注意:this指向的对象函数结束后不会销毁,故以引用方式返回提高效率
Date& operator++()
{
_day += 1;
return *this;
}
// 后置++:
// 前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载
// C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器
自动传递
// 注意:后置++是先使用后+1,因此需要返回+1之前的旧值,故需在实现时需要先将this保存
一份,然后给this+1
// 而temp是临时对象,因此只能以值的方式返回,不能返回引用
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
#include <iostream>
using namespace std;
class Date
{
private:
int _year;
int _month;
int _day;
public:
int GetMonthDay(int year, int month)//获取月的天数
{
static int monthDays[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0))//闰年判断:四年一润,百年不润。或者四百年一润。
{
return 29;//闰年2月29天
}
return monthDays[month];
}
Date(int year = 0, int month = 1, int day = 1)
{
if (year >= 0 && month >= 1 && month <= 12 && day >= 1 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "非法日期" << endl;
}
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
cout << "拷贝构造函数调用" << endl;
}
void print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
Date& operator += (int day)
{
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
Date& operator -= (int day)
{
Date ret(*this); // 拷贝构造一个新的对象
_day -= day;
while (_day < 1)
{
_month--;
if (_month < 1)
{
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
// 前置++ ++d1 => d1.operator++(&d1)
Date& operator++()
{
*this += 1;//复用+=重载
return *this;
}
// 后置++ d1++ => d1.operator++(&d1,0) 第二个值无效仅仅起重载作用
Date operator++(int) //仅仅是为了构成重载 随便加了一个int 不会用它的
{
Date temp = *this;
++(*this); // 调用前置++
return temp;//返回加之前的值
}
// 前置--
Date& operator--()
{
*this -= 1;//复用-=重载
return *this;
}
// 后置--
Date operator--(int)
{
Date temp = *this;
--(*this); // 调用前置--
return temp;
}
};
int main()
{
Date d2(1999, 9, 9);
d2.print();
d2 = { 2000,10,10 };// 类对象传置使用大括号{},d2 = ( 2000, 10, 10 ) 这里是逗号表达式选最后一个10传给构造函数的第一个值 , d2 = 2000, 10, 10 是赋值d2 = 2000,逗号的优先级比等号低。
d2.print();
(--d2).print();
(d2--).print();
return 0;
}
表达式 d2 = {2000, 10, 10};
隐式类型转换,构造出tmp(2000,10,10)再拷贝构造d2(tmp)
在C++中,表达式 d2 = {2000, 10, 10};
中确实会发生隐式类型转换和对象的构造和拷贝构造的过程。让我们来解释这个过程:
-
隐式类型转换:当你使用大括号
{}
来初始化一个对象时,会发生隐式类型转换。在这种情况下,编译器会尝试根据提供的值来构造一个临时对象。 -
构造临时对象:根据大括号
{2000, 10, 10}
中提供的值,编译器会构造一个临时对象tmp
,其成员变量被初始化为2000, 10, 10
。 -
拷贝构造:接着,编译器会使用拷贝构造函数将临时对象
tmp
的值拷贝给d2
。这里的拷贝构造是由编译器自动生成的默认拷贝构造函数。
这个过程中,确实会有一个临时对象 tmp
被构造出来,然后将其值拷贝给 d2
。这种语法虽然方便,但在某些情况下可能引入额外的开销,特别是在处理大型对象或需要频繁复制的情况下。
如果你希望避免这种额外的构造和拷贝开销,可以直接初始化 d2
,如下所示:
Date d2{2000, 10, 10}; // 直接初始化,避免临时对象的构造和拷贝
通过直接初始化,可以避免引入临时对象,从而提高效率。
表达式 d2 = (2000, 10, 10);
使用括号 ()
里面的逗号表达式,逗号表达式会依次计算每个表达式,但整个逗号表达式的结果是最后一个表达式的值,即d2 = 10。
表达式 d2 = 2000, 10, 10;
中的逗号运算符 ,
的优先级比赋值运算符 =
低。因此,这条语句会被解释为 (d2 = 2000), 10, 10;
,即先执行赋值操作 d2 = 2000
,然后返回值为 2000
,而后面的 10, 10
只是简单的表达式,没有影响。
const成员
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数 隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
在 C++ 中,将 const
修饰类的成员函数,实际上是将该成员函数隐含地应用于 this
指针,并表明在该成员函数中不能对类的任何非 mutable
成员进行修改。
当一个成员函数被声明为 const
成员函数时,这意味着该函数承诺不会修改任何非 mutable
数据成员。在 const
成员函数内部,this
指针被视为指向常量对象,因此不能通过 this
指针来修改类的数据成员,除非这些数据成员被声明为 mutable
。
mutable
关键字用于声明类的数据成员可以在 const
成员函数内被修改。通常情况下,const
成员函数被设计为不修改类的数据成员,但有时候可能会有一些特殊情况,需要在 const
成员函数内修改某些数据成员,这时就可以使用 mutable
关键字。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
void Print() const
{
cout << "Print()const" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
void Test()
{
Date d1(2022, 1, 13);
d1.Print();
const Date d2(2022, 1, 13);
d2.Print();
}
int main()
{
Test();
return 0;
}
取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
在 C++ 中,重载取地址操作符 &
和 const 取地址操作符 &
可以帮助您自定义类对象的取地址行为。这两个操作符的重载可以让您以特定的方式返回对象的地址,无论对象是否为常量。下面是关于这两个操作符重载的详细解释:
取地址操作符重载 &
-
目的:
重载取地址操作符&
可以让您定义在非常量对象上获取地址时的行为。 -
重载形式:
在类中重载取地址操作符时,您可以定义一个返回指向对象的地址的函数。通常返回一个指针,该指针指向对象的某个成员或值。 -
示例:
Date* operator&()//Date* { // return this ; }
const 取地址操作符重载 &
-
目的:
重载 const 取地址操作符&
可以让您定义在常量对象上获取地址时的行为。 -
重载形式:
当您想要在常量对象上获取地址时,可以重载 const 取地址操作符,以便返回适当的指针。 -
示例:
const Date* operator&()const //const Date* { // return this ; }
使用场景:
- 特定需求:重载取地址操作符通常用于特殊的需求,例如需要返回对象中的某个成员的地址而不是整个对象的地址。
- 定制行为:通过重载这两个操作符,您可以定制对象地址的返回行为,从而使类更加灵活和符合特定设计需求。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
初始化列表
在C++中,初始化列表(initializer list)是用于在构造函数中初始化类成员变量的一种特殊语法。初始化列表位于构造函数的参数列表之后,由冒号(:)开头,后面跟着一个用逗号分隔的成员变量初始化列表。
1. 为什么使用初始化列表:
- 效率:初始化列表可以避免在构造函数体内对成员变量进行赋值操作,直接在对象构造时完成初始化,提高效率。
- 常量成员和引用成员:初始化列表是初始化常量成员和引用成员的唯一方法,因为它们必须在构造函数体之外进行初始化。
2. 初始化列表的语法:
class ClassName {
public:
// Constructor with initializer list
ClassName(type1 param1, type2 param2, ...) : member1(param1), member2(param2), ... {
// Constructor body
}
private:
dataType member1;
dataType member2;
// Other members
};
3. 初始化列表的特点:
- 指定成员初始化:在初始化列表中,可以为每个成员变量指定初始化值,无需在构造函数体内进行单独的初始化操作。
- 成员变量初始化顺序:类的成员变量的初始化顺序是由它们在类中的声明顺序决定的,而不是它们在初始化列表中的顺序。
- 常量成员和引用成员初始化:常量成员和引用成员必须在初始化列表中进行初始化。
- 调用基类构造函数:可以在初始化列表中调用基类的构造函数。
- 逗号分隔:在初始化列表中,每个成员变量初始化都以逗号分隔,最后不需要分号。
4. 示例:
#include <iostream>
using namespace std;
class A
{
public:
A(int a)
:_a1(a) // 初始化 _a1 成员变量
,_a2(_a1) // 初始化 _a2 成员变量,这里使用了 _a1 的值
{}
void Print()
{
cout << _a1 << " " << _a2 << endl; // 输出 _a1 和 _a2 的值
}
private:
int _a2; // 定义 _a2 成员变量
int _a1; // 定义 _a1 成员变量
};
int main()
{
A aa(1); // 创建 A 类的对象 aa,并初始化为 1
aa.Print(); // 调用 Print 函数输出 _a1 和 _a2 的值
}
在构造函数中的初始化列表中 _a2(_a1)
使用了 _a1
来初始化 _a2
,这可能会导错误,因为 _a1
和 _a2
的初始化顺序是由它们在类中的声明顺序决定的,而不是初始化列表中的顺序。
由于 _a2
在 _a1
前面声明,因此 _a2
会先初始化,然后 _a2
会使用未初始化的 _a1
来初始化,会导致结果未定义。
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟 一个放在括号中的初始值或表达式。
【注意】
1. 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
2. 类中包含以下成员,必须放在初始化列表位置进行初始化:
引用成员变量
const成员变量
自定义类型成员(且该类没有默认构造函数时)
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
#include <iostream>
class A {
public:
A(int a) : _a(a) {}
private:
int _a;
};
class B {
public:
B(int a, int ref) : _aobj(a), _ref(ref), _n(10) {}
private:
A _aobj;
int& _ref;
const int _n;
};
int main() {
int a = 5;
int b = 15;
B obj(a, b);
return 0;
}
尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量, 一定会先使用初始化列表初始化。
如果两个成员变量之间存在依赖关系,比如一个成员变量的初始化需要依赖于另一个成员变量已经被正确初始化,那么确保在初始化列表中按照正确的顺序初始化这些成员变量是非常重要的。否则,会导致未定义行为或程序逻辑错误。
#include <iostream>
class MyClass {
public:
int y;
int x;
MyClass(int a) : x(a), y(x + 10) {
// 这里y的初始化依赖于x,但是x在y之后初始化
}
};
int main() {
MyClass obj(5);
std::cout << "x: " << obj.x << ", y: " << obj.y << std::endl;
return 0;
}
在这个示例中,MyClass
类的构造函数尝试先将 x
初始化为传入的参数 a
,然后将 y
初始化为 x + 10
。然而,由于初始化列表中 y
在 x
之后初始化,这会导致 y
的初始化使用了未定义的 x
值,因此程序的行为是未定义的。
因此,确保在初始化列表中按照正确的顺序初始化成员变量可以避免这类问题,尤其是当存在成员变量之间的依赖关系时。
在C++中,常量成员和引用成员有特殊的初始化要求,它们必须在对象构造时被初始化,并且一旦初始化后就不能再被修改。这种特性导致了初始化常量成员和引用成员的必要性,而初始化列表是唯一的途径来实现这一目的,因为它们必须在构造函数体之外进行初始化。
常量成员的特性和初始化限制:
-
常量成员必须在声明时初始化:
常量成员在类的声明中就必须初始化,一旦初始化后就不能再次赋值。 -
只能在初始化列表中初始化:
由于常量成员在类声明时就要求初始化,所以在构造函数体内赋值已经违反了常量成员的初始化规则。
引用成员的特性和初始化限制:
-
引用成员必须在构造函数中初始化:
引用成员在对象构造时必须指向一个已存在的对象,而且一旦初始化之后,无法再指向其他对象。 -
无法重新赋值:
引用成员一旦被初始化指向一个对象,就无法再次赋值给其他对象。
为什么使用初始化列表:
-
保证正确初始化:
初始化列表确保在对象构造时,常量成员和引用成员得到正确的初始值。 -
语法要求:
C++语法规定常量成员和引用成员必须在初始化列表中进行初始化。
#include <iostream>
class Example {
public:
const int constantValue; // 常量成员
int& referenceValue; // 引用成员
// 错误的尝试在构造函数体内初始化常量和引用成员
Example(int a, int& b) {
constantValue = a; // 错误:常量成员无法在构造函数体内赋值
referenceValue = b; // 错误:引用成员无法在构造函数体内赋值
}
void displayValues() {
std::cout << "常量值: " << constantValue << std::endl;
std::cout << "引用值: " << referenceValue << std::endl;
}
};
int main() {
int x = 10;
int y = 20;
Example obj(5, x); // 创建对象并尝试初始化常量和引用成员
x = 100; // 修改原始值 x
obj.displayValues(); // 显示常量和引用成员的值
return 0;
}
#include <iostream>
class Example {
public:
const int constantValue; // 常量成员
int& referenceValue; // 引用成员
// 使用初始化列表来初始化常量和引用成员的构造函数
Example(int a, int& b) : constantValue(a), referenceValue(b) {
// 其他构造函数操作
}
void displayValues() {
std::cout << "常量值: " << constantValue << std::endl;
std::cout << "引用值: " << referenceValue << std::endl;
}
};
int main() {
int x = 10;
int y = 20;
Example obj(5, x); // 使用初始化列表初始化常量和引用成员
x = 100; // 修改原始值 x
obj.displayValues(); // 显示常量和引用成员的值
return 0;
}
explicit关键字
在 C++ 中,explicit
是一个关键字,用于修饰只接受一个参数的构造函数,以防止编译器执行隐式类型转换。当一个构造函数被 explicit
修饰时,它将不再允许隐式转换,而只能通过显式调用来创建对象。
为什么使用 explicit
关键字:
-
防止隐式类型转换:通过使用
explicit
关键字,可以防止编译器在某些情况下执行隐式类型转换,从而避免潜在的意外行为。 -
明确代码意图:明确指定构造函数只能以显式方式调用,可以提高代码的可读性,减少代码中的歧义。
#include <iostream>
class Example {
public:
int value;
// 使用 explicit 关键字修饰构造函数
explicit Example(int val) : value(val) {}
void displayValue() {
std::cout << "Value: " << value << std::endl;
}
};
int main() {
Example obj1 = 5; // 编译错误,因为构造函数使用了 explicit 关键字
Example obj2(10); // 正确,使用显式调用来创建对象
obj2.displayValue();
return 0;
}
在这个示例中,Example
类中的构造函数被标记为 explicit
,这意味着你不能像 Example obj1 = 5;
这样隐式地创建对象。相反,你必须使用显式调用,例如 Example obj2(10);
。这样可以确保构造函数只能以显式方式调用,避免了可能导致意外行为的隐式类型转换。
在 C++ 中,隐式创建对象和显式调用构造函数之间的区别在于对象的创建方式。
隐式创建对象:
隐式创建对象是指在不使用直接构造函数调用语法的情况下创建对象。这通常发生在以下情况下:
-
使用等号进行赋值初始化:
Example obj = 5; // 隐式创建对象,编译器执行隐式类型转换
-
作为函数参数传递:
void someFunction(Example obj); someFunction(10); // 隐式创建对象,编译器执行隐式类型转换
显式调用构造函数:
显式调用构造函数是指通过直接调用构造函数来创建对象。
-
使用括号语法:
Example obj(10); // 显式调用构造函数来创建对象
-
使用花括号语法:
Example obj{10}; // 也可以使用花括号语法来显式调用构造函数
区别:
- 隐式创建对象允许编译器执行隐式类型转换,将右侧的值转换为对象的类型,然后初始化对象。这可能会导致意外行为和潜在的错误。
- 显式调用构造函数要求明确指定构造函数的调用,避免了隐式类型转换带来的潜在问题,提高了代码的可读性和安全性。
通过使用 explicit
关键字修饰构造函数,可以禁止隐式创建对象,从而确保对象的创建是通过显式调用构造函数来完成的,避免了潜在的错误和不确定性。
构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值 的构造函数,还具有类型转换的作用。
static成员
在C++中,static
成员是属于类本身而不是类的实例的成员。这意味着无论创建了多少个类的实例,static
成员只有一份拷贝。static
成员可以用于在类的所有实例之间共享数据或功能。
下面是一些关于 static
成员的重要性质和用法:
-
静态数据成员:可以用关键字
static
来声明类的静态数据成员。静态数据成员与类的实例无关,它们属于整个类。静态数据成员在类的所有实例之间共享,只有一份拷贝。class MyClass { public: static int count; // 静态数据成员声明 }; int MyClass::count = 0; // 静态数据成员定义和初始化
-
静态成员函数:可以使用
static
关键字来声明类的静态成员函数。静态成员函数不操作特定实例的数据,因此它们无法访问非静态成员变量或函数。class MyClass { public: static void staticFunction() { // 静态成员函数的实现 } };
-
静态成员的访问:静态成员可以使用类名和作用域解析运算符
::
来访问,也可以在类的实例中通过点运算符.
来访问。MyClass::count = 10; // 访问静态数据成员 MyClass::staticFunction(); // 调用静态成员函数
-
静态成员的初始化:静态数据成员通常在类的定义外部进行初始化,以确保只有一次初始化。
int MyClass::count = 0; // 静态数据成员的初始化
通过使用静态成员,可以实现类范围的数据共享和独立于类实例的功能。
声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用 static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化
在 C++ 中,对于静态成员变量,一般情况下确实需要在类的外部进行初始化。这是因为静态成员变量只是在类中声明,而不是定义。编译器需要知道这个静态成员变量的存储位置,因此需要在类的外部进行定义和初始化。
下面是一些关于为什么静态成员变量一定要在类外进行初始化的原因:
-
只能在类外定义静态成员变量:在类中声明静态成员变量只是告诉编译器这个变量的存在,但并没有为其分配内存空间。因此,需要在类的外部进行定义,为其分配内存空间。
-
避免重复定义:如果静态成员变量在类的头文件中进行定义,当多个源文件包含这个头文件时,会导致多重定义错误。将静态成员变量的定义放在类的实现文件中可以避免这种问题。
-
确保初始化顺序:在类的外部进行初始化可以确保静态成员变量在程序启动时被正确初始化。C++中静态成员变量的初始化顺序是按照它们的声明顺序进行的,因此在类外部初始化可以更好地控制初始化顺序。
示例代码如下:
class MyClass {
public:
static int staticVar; // 只是声明,未分配内存空间
};
int MyClass::staticVar = 10; // 在类外进行定义和初始化
int main() {
int value = MyClass::staticVar; // 访问静态成员变量
return 0;
}
因此,为了正确地定义和初始化静态成员变量,并避免重复定义和确保正确的初始化顺序,静态成员变量通常需要在类的外部进行定义和初始化。
特性
-
静态成员为所有类对象所共享:静态成员变量属于整个类,而不是类的特定对象。它们在内存中的存储位置是在静态存储区,而不是在每个对象的实例中。
-
静态成员变量的定义:静态成员变量必须在类的外部进行定义,定义时不需要再添加static关键字。在类中只需进行声明,告诉编译器该静态成员变量的存在。
-
类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问
-
静态成员函数特性:静态成员函数属于整个类,而不是类的特定对象。它们没有this指针,因此无法访问非静态成员变量或函数,只能访问静态成员和其他静态成员函数。
-
访问限定符的限制:静态成员也受访问限定符(public、protected、private)的限制。这意味着静态成员的访问权限受到类的访问控制机制的约束,从而影响到外部对静态成员的访问。
作用域解析运算符 ::
在C++中,作用域解析运算符 ::
用于访问全局作用域、命名空间、类、结构体或枚举的成员,以及静态成员和静态函数。这个运算符可以帮助指定要使用的特定作用域,以避免歧义或确保访问正确的成员。
以下是 ::
的一些常见用法:
-
全局作用域:
::
可用于访问全局命名空间中的变量或函数。#include <iostream> int globalVar = 10; int main() { int globalVar = 20; ::globalVar = 30; // 使用作用域解析运算符来访问全局变量 //globalVar = 30; // 如果不加::,这行会修改局部变量而不是全局变量 std::cout << "Global variable value: " << globalVar << std::endl; // 访问局部变量 std::cout << "Global variable value using ::: " << ::globalVar << std::endl; // 访问全局变量 return 0; }
-
命名空间:
::
可用于访问命名空间中的成员。namespace MyNamespace { int value = 5; } int main() { int value = 10; int namespaceValue = MyNamespace::value; // 访问命名空间中的变量 return 0; }
-
类作用域:用于访问类的静态成员、静态函数或内部类。
class MyClass { public: static int staticVar; }; int MyClass::staticVar = 100; int main() { int classStaticVar = MyClass::staticVar; // 访问类的静态成员 return 0; }
-
解析多重继承:在多重继承中,
::
可用于指定访问哪个基类中的成员。class Base1 { public: int value = 10; }; class Base2 { public: int value = 20; }; class Derived : public Base1, public Base2 { public: void showValues() { int val1 = Base1::value; int val2 = Base2::value; } };
通过使用作用域解析运算符 ::
,可以明确指定要访问的特定作用域中的成员,从而确保代码的准确性和可读性。
静态成员函数可以直接调用类的静态成员,但不能直接调用非静态成员,因为静态成员函数没有this指针。非静态成员函数可以直接调用静态成员函数和非静态成员函数。
#include <iostream>
class MyClass
{
public:
static void staticFunction()
{
std::cout << "Static function called" << std::endl;
// 静态成员函数可以直接调用类的静态成员函数
anotherStaticFunction();
// 不能直接调用非静态成员函数,会导致编译错误
//nonStaticFunction(); // 这里会导致编译错误
}
void nonStaticFunction()
{
std::cout << "Non-static function called" << std::endl;
// 非静态成员函数可以直接调用类的静态成员函数和非静态成员函数
staticFunction();
anotherStaticFunction();
}
static void anotherStaticFunction()
{
std::cout << "Another static function called" << std::endl;
}
};
int main() {
MyClass::staticFunction();
MyClass obj;
obj.nonStaticFunction();
return 0;
}
友元
在 C++ 中,友元(friend)是一种特殊的机制,允许一个函数或类访问另一个类的私有成员。友元能够突破类的封装性,允许外部函数或类访问类的私有和受保护成员,从而提供更灵活的访问控制。
友元提供了一种突破封装的方式,提供了便利,但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元分为:友元函数和友元类
友元函数
友元函数(friend function)是C++中的一种特殊函数,被声明为某个类的友元。友元函数能够访问该类的私有成员和受保护成员,即使这些成员在类中被声明为私有或受保护。
友元函数的特点包括:
声明方式:在类的内部声明友元函数,但在类的外部定义友元函数。通过在类中声明函数为友元,该函数就被赋予了对类的私有和受保护成员的访问权限。
访问权限:友元函数具有访问类的私有成员的特权,可以直接访问类的私有成员变量和私有成员函数。
不属于类:友元函数不是类的成员函数,因此它没有this指针,也不能通过类的对象调用。
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在 类的内部声明,声明时需要加friend关键字。
友元函数在 C++ 中的特性包括:
-
访问权限:友元函数可以访问类的私有和受保护成员,因为它被声明为类的友元。这使得友元函数能够绕过类的封装性,直接访问这些成员。
-
不是类的成员函数:友元函数不是类的成员函数,因此它没有this指针,也不能通过类的对象调用。友元函数独立于类存在,但通过友元关系与类相联系。
-
无法用const修饰:友元函数不能被声明为const成员函数,因为它没有this指针不是类的成员函数,也不具有类成员函数的特性。
-
声明位置不受限制:友元函数可以在类定义的任何地方进行声明,不受类的访问限定符的限制。这使得在类中可以灵活地声明友元函数。
-
一个函数可以是多个类的友元函数:一个函数可以被多个不同的类声明为友元函数,从而允许该函数访问多个类的私有和受保护成员。
-
调用方式:友元函数的调用方式与普通函数的调用原理相同,可以直接调用友元函数,不需要通过类的对象来调用。
#include <iostream>
class Point
{
private:
int x;
int y;
public:
Point(int x = 0, int y = 0) : x(x), y(y) {}
// 声明友元函数
friend std::ostream& operator<<(std::ostream& os, const Point& point);
// 用于输出坐标点信息的成员函数
void display() const
{
std::cout << "(" << x << ", " << y << ")";
}
};
// 友元函数的定义
std::ostream& operator<<(std::ostream& os, const Point& point)
{
os << "(" << point.x << ", " << point.y << ")";
return os;
}
int main()
{
Point p(3, 4);
// 使用重载的 << 运算符输出对象信息
std::cout << "Point p: " << p << std::endl;
return 0;
}
友元类
-
友元类的成员函数可以访问另一个类的非公有成员:当一个类被声明为另一个类的友元类时,该友元类的所有成员函数都可以访问另一个类中的私有和受保护成员。
-
友元关系是单向的:如果类A将类B声明为友元类,那么类B的成员函数可以访问类A的私有成员。然而,反过来,类A的成员函数不能访问类B的私有成员。
-
友元关系不能传递:即使类C是类B的友元类,类B是类A的友元类,也不能推断出类C是类A的友元类。友元关系不具有传递性。
-
友元关系不能继承:如果类B是类A的友元类,而类C继承自类A,类C并不会自动成为类B的友元类。友元关系不会被继承。每个类必须显式地声明其友元关系。
#include <iostream>
class Date;
class Time
{
private:
int hour;
int minute;
public:
Time(int h, int m) : hour(h), minute(m) {}
// 声明 Date 类为友元类
friend class Date;
};
class Date
{
private:
int day;
int month;
int year;
public:
Date(int d, int m, int y) : day(d), month(m), year(y) {}
// Date 类的成员函数可以访问 Time 类的私有成员
void displayTime(const Time& t)
{
std::cout << "Time: " << t.hour << ":" << t.minute << std::endl;
}
void displayDate(const Date& d);
};
// Date 类的成员函数的定义,显示日期信息
void Date::displayDate(const Date& d)
{
std::cout << "Date: " << d.day << "/" << d.month << "/" << d.year << std::endl;
}
int main()
{
Date date(8, 9, 2024);
Time time(15, 30);
date.displayDate(date); // 显示日期信息
date.displayTime(time); // 显示时间信息
return 0;
}
内部类
如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类, 它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何的访问权限。
注意:内部类就是外部类的友元类,参见友元类的定义,内部类可以通过外部类的对象参数来访 问外部类中的所有成员。但是外部类不是内部类的友元。
-
内部类的访问限定符:
内部类可以被定义在外部类的public
、protected
、private
部分,这取决于您希望内部类的访问级别是什么。这允许您根据需要对内部类的可见性进行控制。 -
内部类访问外部类的静态成员:
内部类可以直接访问外部类中的静态成员,无需外部类的对象或类名。这使得内部类能够方便地访问外部类的静态数据成员或静态成员函数。 -
内部类和外部类大小:
sizeof(外部类)
表示外部类的大小,与内部类没有直接关系。编译器会在内部类和外部类之间进行适当的分配和管理,因此内部类的大小不会直接影响外部类的大小。
#include <iostream>
class Outer
{
private:
int outer_private_var;
public:
Outer(int value) : outer_private_var(value) {}
void display()
{
std::cout << "Outer class private variable: " << outer_private_var << std::endl;
}
class Inner //内部类天生就是外部类的友元
{
public:
void accessOuter(Outer& outer)
{
std::cout << "Accessing outer class private variable from inner class: " << outer.outer_private_var << std::endl;
}
};
};
int main()
{
Outer outerObj(100);
outerObj.display();
Outer::Inner innerObj;
innerObj.accessOuter(outerObj);
return 0;
}
匿名对象
匿名对象是在创建时没有指定名称的对象。这些对象通常用于临时执行某些操作而不需要在后续代码中引用它们。匿名对象可以在需要对象的地方直接创建并使用。
#include <iostream>
using namespace std;
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(" << _a << ")" << endl;
}
~A()
{
cout << "~A(" << _a << ")" << endl;
}
private:
int _a;
};
int main()
{
A a1(1);
//A a1();不能这么定义对象,因为编译器无法识别下面是一个函数声明,还是对象定义。
A(2);// 匿名对象他的生命周期只有这一行,可以看到下一行他就会自动调用析构函数
A a2(3);
return 0;
}
拷贝对象时的一些编译器优化
在传参和传返回值的过程中,一般编译器会做一些优化,减少对象的拷贝。
#include <iostream>
using namespace std;
class A
{
public:
A(int a = 0):_a(a)
{
cout << "A(int a)" << endl;
}
A(const A& aa):_a(aa._a)
{
cout << "A(const A& aa)" << endl;
}
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a = aa._a;
}
return *this;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void f1(A aa){}
A f2()
{
A aa;
return aa;
}
int main()
{
// 传值传参
A aa1;
f1(aa1);
cout << endl;
// 传值返回
f2();
cout << endl;
// 隐式类型,连续构造+拷贝构造->优化为直接构造
f1(1);
cout << endl;
// 一个表达式中,连续构造+拷贝构造->优化为一个构造
f1(A(2));
cout << endl;
// 一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造
A aa2 = f2();
cout << endl;
// 一个表达式中,连续拷贝构造+赋值重载->无法优化
aa1 = f2();
cout << endl;
return 0;
}
在C++中,编译器可以通过复制省略或返回值优化等技术来避免不必要的对象拷贝,从而提高代码的效率。下面是一些常见的拷贝构造函数的优化情况:
返回值优化 (Return Value Optimization, RVO):
当函数返回一个临时对象时,编译器可以优化以避免生成临时对象的拷贝。编译器可以直接在调用方的对象中构造临时对象,避免额外的拷贝操作。
命名返回值优化 (Named Return Value Optimization, NRVO):
类似于RVO,但是在函数中显式声明了一个对象,并返回该对象时,编译器也可以通过NRVO优化,避免不必要的拷贝操作。
拷贝省略 (Copy Elision):
标准中规定,编译器可以在某些情况下避免执行拷贝构造函数,即使语法上需要进行拷贝构造。这种情况下,对象的构造直接在目标对象内部进行,而不进行显式的拷贝操作。
构造函数合并:
当一个临时对象通过构造函数传递给另一个函数时,编译器可能会将这两个构造函数合并为一个,避免临时对象的创建和销毁,从而提高效率。
这些优化技术的应用可以大大提高程序性能,尤其在涉及大量对象拷贝的情况下。然而,这些优化是由编译器自动完成的,开发者无需显式进行操作。在实践中,编译器会根据代码的具体情况自动选择是否进行这些优化,以提高程序效率。
类和对象
在面向对象编程中,类和对象是核心概念。下面是关于类和对象的简要解释:
类(Class):
定义:类是一种用户定义的数据类型,用来表示一类对象的属性和行为。
特点:类定义了对象的结构,包括属性(数据成员)和行为(成员函数)。
示例:例如,Car类可以包括属性如color和speed,以及行为如accelerate()和brake()。
对象(Object):
定义:对象是类的实例,是内存中具体存在的实体,具有类定义的结构和行为。
特点:对象是类的具体化,每个对象都有自己的属性值,但共享类的方法。
示例:如果Car是一个类,那么myCar和yourCar可以是Car类的两个对象,每个对象都有自己的颜色和速度值。
类与对象的关系:
类是模板,对象是实例:类定义了对象的结构和行为,而对象是根据类创建的具体实例。
类的多个对象:可以根据一个类创建多个对象,每个对象都有自己的属性值,但共享类的方法。
封装性:类通过封装数据和行为,提供了一种机制来组织相关的数据和功能,防止对数据的直接访问。
继承性:类可以通过继承机制派生出新的类,从而实现代码重用和层次化设计。
多态性:通过多态性,不同对象可以对相同的消息作出不同的响应,实现了接口和实现的分离。
总体来说,类定义了对象的结构和行为,而对象是类的实例,具有类定义的属性和方法。通过类和对象的使用,可以实现面向对象编程的核心概念,如封装、继承和多态。
C/C++内存管理
在 C/C++ 中,内存管理是由程序员手动控制的,这包括内存的分配和释放。
内存分配:
栈上分配:
自动分配内存在栈上,比如函数的局部变量。
自动分配的内存在函数执行完毕时自动释放。
堆上分配:
动态分配内存在堆上,通过 new(C++)或 malloc(C)等函数。
程序员负责手动释放这部分内存,否则可能导致内存泄漏。
内存释放:
释放堆内存:
使用 delete(C++)或 free(C)释放通过 new 或 malloc 分配的内存。
忘记释放堆内存可能导致内存泄漏,即程序无法再访问被分配的内存,但该内存仍被占用。
避免悬空指针:
在释放内存后,将指针值设为 nullptr(C++)或 NULL(C)可以避免悬空指针问题。
C语言中动态内存管理方式:malloc/calloc/realloc/free
在 C++ 中,delete 和 delete[] 是用于释放动态分配内存的操作符。它们的主要区别在于它们处理的内存类型和释放的方式。以下是这两者的详细说明:1. delete
用法:
delete 用于释放通过 new 分配的单个对象的内存。
int* ptr = new int(5); // 动态分配一个整数
delete ptr; // 释放该整数的内存
适用场景:
仅适用于通过 new 分配的单一对象。
2. delete[]
用法:
delete[] 用于释放通过 new[] 分配的数组的内存。
int* arr = new int[5]; // 动态分配一个整数数组
delete[] arr; // 释放该数组的内存
适用场景:
仅适用于通过 new[] 分配的数组。
3. 内存释放的区别
单个对象:
使用 delete 时,析构函数会被调用一次,释放单个对象的资源。
数组:
使用 delete[] 时,析构函数会被调用每个数组元素的次数,确保每个元素的资源都被正确释放。
4. 示例代码
以下是一个示例,展示了 delete 和 delete[] 的使用:
#include <iostream>
class MyClass {
public:
MyClass() { std::cout << "Constructor\n"; }
~MyClass() { std::cout << "Destructor\n"; }
};
int main() {
// 使用 delete
MyClass* obj = new MyClass(); // 创建单个对象
delete obj; // 释放单个对象
// 使用 delete[]
MyClass* arr = new MyClass[2]; // 创建对象数组
delete[] arr; // 释放对象数组
return 0;
}
5. 总结
delete:用于释放通过 new 分配的单个对象。
delete[]:用于释放通过 new[] 分配的数组。
使用不当(如使用 delete 释放数组或使用 delete[] 释放单个对象)会导致未定义行为,因此确保匹配使用非常重要。
C++内存管理方式
在C++中,内存管理主要通过以下几种方式进行:
栈内存管理:
在函数内部声明的局部变量通常存储在栈上,当函数执行完毕时,这些变量会被自动释放。
栈内存管理是自动的,无需手动释放内存,但是作用域仅限于定义它们的块内部。
堆内存管理:
堆内存是动态分配的,需要程序员手动分配和释放。主要使用new和delete或new[]和delete[]来分配和释放内存。
手动管理堆内存使得程序员有更大的灵活性,但也容易导致内存泄漏和悬挂指针等问题。
智能指针:
C++11引入了智能指针,如std::unique_ptr和std::shared_ptr,用于管理堆内存,避免手动释放内存带来的问题。
std::unique_ptr确保在其生命周期结束时自动释放内存,而std::shared_ptr使用引用计数来管理内存。
容器类:
C++标准库提供了许多容器类,如std::vector、std::list等,它们可以自动管理内存。
使用这些容器类可以简化内存管理,并且提供了一些高级功能,如动态增长大小、自动释放内存等。
RAII(资源获取即初始化):
RAII是一种C++的编程范式,通过在对象的构造函数中获取资源,在析构函数中释放资源,实现资源的自动管理。
RAII能够确保资源在对象生命周期结束时被正确释放,是一种重要的内存管理方式。
new/delete操作内置类型
#include <iostream>
using namespace std;
void Test()
{
// 动态申请一个 int 类型的空间
int* ptr4 = new int;
cout << "Size of dynamic int (ptr4): " << sizeof(*ptr4) << " bytes" << endl;
// 动态申请一个 int 类型的空间并初始化为 10
int* ptr5 = new int(10);
cout << "Size of dynamic int (ptr5): " << sizeof(*ptr5) << " bytes" << endl;
cout << "Value of dynamic int (ptr5): " << *ptr5 << endl;
// 动态申请 10 个 int 类型的空间
int* ptr6 = new int[10];
cout << "Size of dynamic int array (ptr6): " << sizeof(*ptr6) * 10 << " bytes" << endl;
delete ptr4;
delete ptr5;
delete[] ptr6;
}
int main()
{
// C 函数
int* p1 = (int*)malloc(sizeof(int));
cout << "Size of int (p1): " << sizeof(*p1) << " bytes" << endl;
free(p1);
int* p3 = (int*)malloc(sizeof(int) * 10);
cout << "Size of int array (p3): " << sizeof(*p3) * 10 << " bytes" << endl;
free(p3);
// C++ 操作符
int* p2 = new int;
cout << "Size of dynamic int (p2): " << sizeof(*p2) << " bytes" << endl;
delete p2;
int* p4 = new int[10];
cout << "Size of dynamic int array (p4): " << sizeof(*p4) * 10 << " bytes" << endl;
delete[] p4;
Test();
return 0;
}
注意:申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用 new[ ]和delete[ ],注意:匹配起来使用。
new和delete操作自定义类型
注意:在申请自定义类型的空间时,new会调用构造函数,delete会调用析构函数,而malloc与 free不会。
new/delete 和 malloc/free最大区别是 new/delete对于【自定义类型】除了开空间 还会调用构造函数和析构函数
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "调用构造函数\n"; // 在构造函数中打印消息
_a = 6; // 初始化私有成员变量 _a 为 6
}
~A()
{
cout << "调用析构函数\n"; // 在析构函数中打印消息
}
int Return()
{
return _a; // 返回私有成员变量 _a 的值
}
private:
int _a; // 私有成员变量
};
int main()
{
A* p1 = (A*)malloc(sizeof(A)); // 直接使用 malloc 分配内存空间
cout << p1->Return() << endl; // 访问对象成员函数,可能会出现未定义行为
free(p1); // 直接使用 free 释放内存空间
cout << endl;
A* p2 = new A; // 使用 new 先分配内存空间再调用构造函数
cout << p2->Return() << endl; // 访问对象成员函数
delete p2; // 先调用析构函数再释放内存空间
long* p3 = new long[3] {22,55,66}; // 使用 new 动态分配 long 类型数组
cout << p3[2] << endl; // 访问数组元素
cout << *(p3+1) << endl; // 通过指针算术访问数组元素
delete[] p3; // 使用 delete[] 释放动态分配的数组内存空间
return 0;
}
#include <iostream>
using namespace std;
class A
{
public:
A()
{
count++; // 每次调用构造函数时递增 count
cout << "调用构造函数" << count << endl;
_a = 6;
}
~A()
{
cout << "调用析构函数" << count << endl;
count--; // 每次调用析构函数时递减 count
}
int Return()
{
return _a;
}
private:
int _a;
static int count; // 静态成员变量用于跟踪对象数量
};
int A::count = 0; // 初始化静态成员变量 count
int main()
{
A a[10]; // 创建一个包含 10 个对象的数组,调用 10 次构造函数和析构函数
A* p = new A[10]; // 使用 new 创建包含 10 个对象的数组,调用 10 次构造函数
cout << "-------------------------------------" << endl;
delete[] p; // 使用 delete[] 调用 10 次析构函数
return 0;
}
operator new与operator delete函数
new和delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过 operator delete全局函数来释放空间。
operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间 失败时尝试执行空间不足应对措施,如果该应对措施用户设置了,则继续申请,否则抛异常。
如果申请内存失败了,这里会抛出bad_alloc 类型异常。
operator delete: 该函数最终是通过free来释放空间的。
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "A() " << endl;
}
~A()
{
cout << "~A() " << endl;
}
private:
int _a;
};
int main()
{
A* p1 = (A*)malloc(sizeof(A));
A* p2 = new A;
A* p3 = (A*)operator new(sizeof(A));
//operator new 与 malloc的区别: 使用方式都一样,处理错误的方式不一样。
size_t size = 30;//无符号类型
void* p4 = malloc(size * 1024 * 1024 * 1024);//转换成无符号类型(int 有符号超出范围)
//申请失败返回NULL=0
cout << p4 << endl;
//free(p4);
try
{
void* p5 = operator new (size * 1024 * 1024 * 1024);
cout << p5 << endl;
//operator delete (p5);
}
catch (exception& e)
{
cout << e.what() << endl;//bad allocation
}
return 0;
}
new和delete的实现原理
内置类型
如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是: new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。
自定义类型
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
// try to allocate size bytes
void *p;
while ((p = malloc(size)) == 0)
{
if (_callnewh(size) == 0)
{
// report no memory
// 如果申请内存失败了,这里会抛出bad_alloc 类型异常
static const std::bad_alloc nomem;
_RAISE(nomem);
}
}
return (p);
}
void operator delete(void *pUserData)
{
_CrtMemBlockHeader * pHead;
RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));
if (pUserData == NULL)
return;
_mlock(_HEAP_LOCK); /* block other threads */
__TRY
/* get a pointer to memory block header */
pHead = pHdr(pUserData);
/* verify block type */
_ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));
_free_dbg( pUserData, pHead->nBlockUse );
__FINALLY
_munlock(_HEAP_LOCK); /* release other threads */
__END_TRY_FINALLY
return;
}
#define free(p) _free_dbg(p, _NORMAL_BLOCK)
new的原理
1. 调用operator new函数申请空间
2. 在申请的空间上执行构造函数,完成对象的构造
delete的原理
1. 在空间上执行析构函数,完成对象中资源的清理工作
2. 调用operator delete函数释放对象的空间
new T[N]的原理
1. 调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对
象空间的申请
2. 在申请的空间上执行N次构造函数
delete[]的原理
1. 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
2. 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释
放空间
定位new表达式(placement-new)
定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象。
使用格式:
new (place_address) type或者new (place_address) type(initializer-list)
place_address必须是一个指针,initializer-list是类型的初始化列表
使用场景:
定位new表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化,所以如
果是自定义类型的对象,需要使用new的定义表达式进行显示调构造函数进行初始化。
#include <iostream>
class MyClass {
public:
MyClass(int val) : value(val) {
std::cout << "Constructor called with value: " << value << std::endl;
}
~MyClass() {
std::cout << "Destructor called for value: " << value << std::endl;
}
private:
int value;
};
int main() {
// 分配内存
void* buffer = operator new(sizeof(MyClass));
// 在预分配的内存位置上构造对象
MyClass* obj = new(buffer) MyClass(42);
// 手动调用析构函数
obj->~MyClass();
// 释放内存
operator delete(buffer);
return 0;
}
#include <iostream>
using namespace std;
class A
{
public:
A(int a):_a(a)
{
cout << "A() " << endl;
}
~A()
{
cout << "~A() " << endl;
}
void PrintValue()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p1 = new A(2);
p1->PrintValue();
delete p1;
//显示调用了A的构造函数和析构函数
A* p2 = (A*)operator new(sizeof(A));
new(p2)A(10);//定位new对已经存在的一块空间调用构造函数初始化。定位new/replacement new
//new(空间的指针)类型(参数) 参数可以没有 new(空间的指针)构造函数
p2->PrintValue();
p2->~A();
operator delete(p2);
return 0;
}
malloc/free和new/delete的区别
1.malloc 和 free 是 C 语言中的函数,而 new 和 delete 是 C++ 中的操作符。
2.malloc 分配的内存不会被初始化,而 new 分配的内存会被相应类型的构造函数初始化。
3.使用 malloc 时需要手动计算内存的大小并传递,而 new 只需指定类型,如果需要多个对象,可以使用 [] 操作符指定对象个数。
4.malloc 返回 void*,需要进行强制类型转换,而 new 返回指定类型的指针。
5.malloc 分配失败时返回 NULL,需要进行空指针检查,而 new 分配失败会抛出异常。
6.当申请自定义类型的对象时,malloc 和 free 只分配和释放内存,并不调用构造函数和析构函数,而 new 在分配内存后会调用构造函数初始化对象,delete 在释放内存前会调用析构函数清理资源。
堆内存泄漏(Heap leak)
堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一
块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分
内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
系统资源泄漏
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放
掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
c++模板
C++ 中的模板是一种基于参数化的编程技术,允许程序员编写通用的函数或类,这些函数或类可以在不同类型上工作而不需要重复编写代码。模板在 C++ 中是非常强大且灵活的工具,用于实现泛型编程。
函数模板
泛型编程:编写与类型无关的通用代码,是代码复用的一种手段。模板是泛型编程的基础。
函数模板概念:
函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本。
函数模板格式:template<typename T1, typename T2,......,typename Tn>
返回值类型 函数名(参数列表){}
注意:typename是用来定义模板参数关键字,也可以使用class(切记:不能使用struct代替class)
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供 调用。比如:当用double类型使用函数模板时,编译器通过对实参类型的推演,将T确定为double类型,然 后产生一份专门处理double类型的代码,对于字符类型也是如此。
#include <iostream>
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
int x = 5, y = 10;
double m = 3.5, n = 2.7;
std::cout << add(x, y) << std::endl; // 输出 15
std::cout << add(m, n) << std::endl; // 输出 6.2
return 0;
}
函数模板可以被看作是生成函数的蓝图或模具,它本身并不是实际的函数代码。在使用函数模板时,编译器会根据模板参数的具体类型生成相应的函数实例,从而消除了编写多个相似函数的重复工作。
通过函数模板,程序员可以编写通用的代码,从而增加代码的灵活性和重用性。编译器会根据每次使用模板时提供的具体类型,生成适合该类型的函数定义。这种方式使得代码更易维护,减少了重复编写多个函数的工作量。
总的来说,函数模板在 C++ 中确实将重复的工作交给了编译器来处理,程序员只需要定义模板一次,就可以在需要时实例化生成特定类型的函数,大大简化了代码编写过程。
函数模板的实例化
用不同类型的参数使用函数模板时,称为函数模板的实例化。
模板参数实例化分为:隐式实例化和显式实例化。
隐式实例化:让编译器根据实参推演模板参数的实际类型
#include <iostream>
template<typename T>
T Add(const T& left, const T& right)
{
return left + right;
}
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.6, d2 = 20.6;
//Add(a1, d1);
/*
该语句不能通过编译,因为在编译期间,当编译器看到该实例化时,需要推演其实参类型
通过实参a1将T推演为int,通过实参d1将T推演为double类型,但模板参数列表中只有一个T,
编译器无法确定此处到底该将T确定为int 或者 double类型而报错
注意:在模板中,编译器一般不会进行类型转换操作,因为一旦转化出问题,编译器就需要背锅
*/
// 此时有两种处理方式:1. 用户自己来强制转化 2. 使用显式实例化
std::cout << Add(a1, (int)d1) << std::endl;
return 0;
}
显式实例化:在函数名后的<>中指定模板参数的实际类型
#include <iostream>
template<typename T>
T Add(const T& left, const T& right)
{
return left + right;
}
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.6, d2 = 20.6;
// 显式实例化
std::cout << Add<double>(a1, d1) << std::endl;
return 0;
}
如果类型不匹配,编译器会尝试进行隐式类型转换,如果无法转换成功编译器将会报错。
类模板
#include <iostream>
template <typename T>
class Pair {
private:
T first, second;
public:
Pair(T a, T b) : first(a), second(b) {}
T getMax() {
return (first > second) ? first : second;
}
};
int main() {
Pair<int> intPair(10, 20);
Pair<double> doublePair(3.5, 7.2);
std::cout << intPair.getMax() << std::endl; // 输出 20
std::cout << doublePair.getMax() << std::endl; // 输出 7.2
return 0;
}
类模板的定义格式
template<class T1, class T2, ..., class Tn>
class 类模板名
{
// 类内成员定义
};
// 动态顺序表
// 注意:Vector不是具体的类,是编译器根据被实例化的类型生成具体类的模具
template<class T>
class Vector
{
public:
Vector(size_t capacity = 10)
: _pData(new T[capacity])
, _size(0)
, _capacity(capacity)
{}
// 使用析构函数演示:在类中声明,在类外定义。
~Vector();
void PushBack(const T& data);
void PopBack();
// ...
size_t Size() { return _size; }
T& operator[](size_t pos)
{
assert(pos < _size);
return _pData[pos];
}
private:
T* _pData;
size_t _size;
size_t _capacity;
};
// 注意:类模板中函数放在类外进行定义时,需要加模板参数列表
template <class T>
Vector<T>::~Vector()
{
if (_pData)
delete[] _pData;
_size = _capacity = 0;
}
类模板的实例化
类模板实例化与函数模板实例化不同,类模板实例化需要在类模板名字后跟<>,然后将实例化的类型放在<> 中即可,类模板名字不是真正的类,而实例化的结果才是真正的类。
// Vector类名,Vector<int>才是类型
Vector<int> s1;
Vector<double> s2;
模板参数的匹配原则
一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数。
// 专门处理int的加法函数
int Add(int left, int right)
{
return left + right;
}
// 通用加法函数
template<class T>
T Add(T left, T right)
{
return left + right;
}
void Test()
{
Add(1, 2); // 与非模板函数匹配,编译器不需要特化
Add<int>(1, 2); // 调用编译器特化的Add版本
}
对于非模板函数和同名函数模板,如果其他条件都相同,在调动时会优先调用非模板函数而不会从该模 板产生出一个实例。如果模板可以产生一个具有更好匹配的函数, 那么将选择模板
// 专门处理int的加法函数
int Add(int left, int right)
{
return left + right;
}
// 通用加法函数
template<class T1, class T2>
T1 Add(T1 left, T2 right)
{
return left + right;
}
void Test()
{
Add(1, 2); // 与非函数模板类型完全匹配,不需要函数模板实例化
Add(1, 2.0); // 模板函数可以生成更加匹配的版本,编译器根据实参生成更加匹配的Add函
数
}
模板函数不允许自动类型转换,但普通函数可以进行自动类型转换
template<typename T>
template<class T>
在 C++ 中,template<typename T> 和 template<class T> 都用于声明模板类型参数,它们在实质上没有区别,两者可以互换使用。这两种形式的选择通常是由个人或团队的偏好决定。
在标准 C++ 中,typename 和 class 关键字在模板中用于声明类型参数,它们在大多数情况下是可以互换使用的。实际上,typename 在模板编程中更常用,因为它更清晰地表明参数是一个类型。
#include <iostream>
using namespace std;
template<typename T>//模板类型 参数
void Swap(T& x1,T& x2)
{
T x = x1;
x1 = x2;
x2 = x;
}
int main()
{
int a = 1, b = 2;
float c = 1.6, d = 3.6;
cout << a << "," << b << "," << c << "," << d << endl;
Swap(a, b);
cout << a << "," << b << "," << c << "," << d << endl;
Swap(c, d);
cout << a << "," << b << "," << c << "," << d << endl;
return 0;
}
上面Swap(a, b);和Swap(c, d);是不是调用的同一个函数?不是
因为那只是一个模板,透过现象看本质,虽然调用的是同一个模板函数 Swap,但由于传递的参数类型不同(int 和 float),编译器会分别生成针对整数和浮点数的函数实例。
STL
STL(标准模板库)是 C++ 中一个强大的库,它提供了一系列通用的类和函数,使得数据结构和算法的使用更加方便和高效。
STL 的主要组成部分及其特点:
1. 主要组成部分
1.1 容器(Containers)
容器是用于存储对象的类,STL 提供了多种类型的容器,分为以下几类:
序列容器:
vector:动态数组,可以自动扩展。
deque:双端队列,支持在两端高效插入和删除。
list:双向链表,支持高效的插入和删除。
关联容器:
set:存储唯一元素的集合,自动排序。
map:键值对集合,键唯一,自动排序。
multiset 和 multimap:允许重复元素的集合。
无序容器(C++11 引入):
unordered_set:基于哈希表的集合。
unordered_map:基于哈希表的键值对集合。
1.2 算法(Algorithms)
STL 提供了多种通用算法,可以与容器配合使用,包括:
排序算法:sort, stable_sort
查找算法:find, binary_search
修改算法:copy, transform
其他算法:accumulate, for_each, count
1.3 迭代器(Iterators)
迭代器是用于遍历容器的对象,提供了一种统一的方式来访问容器中的元素。STL 支持多种类型的迭代器:输入迭代器:只读访问。
输出迭代器:只写访问。
前向迭代器:单向遍历。
双向迭代器:可前可后遍历。
随机访问迭代器:支持任意位置访问。
2. 示例代码
以下是一个简单的示例,展示如何使用 STL 的 vector 和 sort 算法:
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> nums = {5, 2, 8, 1, 3};
// 排序
std::sort(nums.begin(), nums.end());
// 输出排序后的元素
for (int num : nums) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
3. 优势
高效性:STL 提供的算法和数据结构经过优化,能够高效处理各种操作。
通用性:模板机制使得 STL 的容器和算法可以用于任意数据类型。
可重用性:通过组合不同的容器和算法,可以轻松构建复杂的数据处理程序。
4. 总结
STL 是 C++ 中一个至关重要的库,它通过提供多种容器、算法和迭代器,极大地简化了数据处理的复杂性。掌握 STL 能够提高编程效率,并使代码更加简洁和可维护。
STL(标准模板库)是 C++ 的一个重要部分,包括多个组件。以下是 STL 的六大主要组件:
1. 容器(Containers)
容器是用于存储对象的类,STL 提供了多种类型的容器,主要分为以下几类:
序列容器:
vector:动态数组,支持随机访问。
deque:双端队列,支持在两端插入和删除。
list:双向链表,支持高效的插入和删除操作。
关联容器:
set:存储唯一元素的集合,自动排序。
map:键值对集合,键唯一,自动排序。
multiset 和 multimap:允许重复元素的集合。
无序容器(C++11 引入):
unordered_set:基于哈希表的集合。
unordered_map:基于哈希表的键值对集合。
2. 算法(Algorithms)
STL 提供了多种通用算法,可以与容器配合使用,包括:
排序算法:sort、stable_sort
查找算法:find、binary_search
修改算法:copy、transform
其他算法:accumulate、for_each、count
3. 迭代器(Iterators)
迭代器是用于遍历容器的对象,提供了一种统一的方式来访问容器中的元素。迭代器的类型包括:
输入迭代器:只读访问。
输出迭代器:只写访问。
前向迭代器:单向遍历。
双向迭代器:可前可后遍历。
随机访问迭代器:支持任意位置访问。
4. 函数对象(Function Objects)
函数对象(或称为仿函数)是可以像函数一样使用的对象。STL 中的许多算法允许使用函数对象作为参数,以实现更灵活的功能。
自定义比较器、算数操作等,都可以通过重载 operator() 来实现。
5. 适配器(Adapters)
适配器可以改变容器和算法的接口,使其更易于使用。主要类型的适配器包括:
容器适配器:
stack:基于其他容器实现的栈。
queue:基于其他容器实现的队列。
priority_queue:优先队列,支持优先级排序。
迭代器适配器:
reverse_iterator:反向迭代器,允许反向遍历容器。
6. 分配器(Allocators)
分配器是用于管理内存的组件,负责分配和释放容器所需的内存。STL 使用分配器来提供内存管理的灵活性,允许用户自定义内存分配策略。
总结
STL 的这六大组件共同构成了一个强大而灵活的工具集,使得 C++ 的数据结构和算法的使用更加高效和简洁。掌握这些组件能够显著提高编程效率和代码质量。
STL的缺陷
虽然 STL(标准模板库)提供了许多强大的功能,但它也存在一些缺陷和局限性。
以下是主要的缺陷:
1. 性能问题
模板实例化:
使用模板时,编译器会为每种类型生成代码,这可能导致二进制文件增大,编译时间变长。
运行时开销:
某些 STL 算法(如 std::sort)在特定情况下性能可能不如手写的专用算法。
2. 内存管理
动态分配:
STL 容器使用动态内存分配,可能导致内存碎片,效率低下。
异常安全:
虽然 STL 提供了一些异常安全保证,但在某些情况下,使用 STL 容器时仍可能造成资源泄漏。
3. 学习曲线
复杂性:
对初学者来说,理解 STL 的各种容器、算法和迭代器的用法可能比较复杂。
模板语法:
C++ 的模板语法较为复杂,可能导致错误信息难以理解,调试较为困难。
4. 功能限制
缺乏线程安全:
STL 容器和算法本身不是线程安全的,需额外处理并发访问的问题。
缺乏某些高级功能:
STL 不支持某些高级数据结构(如图、树等),需要开发者自己实现或使用其他库。
5. 兼容性问题
不同编译器的实现差异:
不同编译器对 STL 的实现可能存在差异,可能导致移植性问题。
6. 设计缺陷
不支持特定类型的容器:
STL 容器在设计上不支持某些特定用例,如非平凡的拷贝或移动行为的对象。
迭代器的复杂性:
迭代器的种类和功能多样,可能让初学者感到困惑,且错误使用可能导致未定义行为。
总结
尽管 STL 提供了高效、灵活的容器和算法,但它的缺陷和局限性也需要开发者在使用时谨慎考虑。了解这些缺陷有助于更好地利用 STL,同时在必要时选择其他数据结构或工具。
标准库中的string类
C++ 标准库中的 string 类是用于处理文本字符串的一个重要类,定义在 <string> 头文件中。它提供了一系列功能强大的操作,简化了字符串的处理。以下是对 C++ 标准库 string 类的详细介绍:
1. 基本特性
动态大小:string 类可以自动调整大小,适应字符串内容的变化。
自动内存管理:不需要手动管理内存,避免了内存泄漏和溢出问题。
支持多种操作:提供丰富的成员函数,支持字符串的各种操作,如拼接、查找和替换等。
2. 常用成员函数
以下是一些常用的 string 类成员函数:
构造函数:
string() 默认构造函数。
string(const char* s) 从 C 风格字符串构造。
string(size_t n, char c) 用字符 c 初始化长度为 n 的字符串。
string(const string&s) 拷贝构造函数
基本操作:
length() 或 size():返回字符串的长度。
empty():检查字符串是否为空,是返回true,否则返回false。
append(const string& str):在字符串末尾追加另一个字符串。
capacity(): 返回空间总大小
clear () 清空有效字符
reserve () 为字符串预留空间
resize () 将有效字符的个数改成n个,多出的空间用字符c填充
查找与替换:
find(const string& str):查找子字符串首次出现的位置。
replace(size_t pos, size_t len, const string& str):替换指定位置的字符。
子字符串:
substr(size_t pos, size_t len):返回从 pos 开始的子字符串。
字符访问:
operator[]:使用下标访问字符。
at(size_t pos):返回指定位置的字符,附带边界检查。
3. 示例代码
以下是一些使用 string 类的示例:
#include <iostream>
#include <string>
int main() {
// 创建字符串
std::string str1 = "Hello, ";
std::string str2 = "World!";
// 拼接字符串
std::string str3 = str1 + str2;
std::cout << str3 << std::endl; // 输出: Hello, World!
// 查找子字符串
size_t pos = str3.find("World");
if (pos != std::string::npos) {
std::cout << "'World' found at position: " << pos << std::endl;
}
// 替换子字符串
str3.replace(pos, 5, "C++");
std::cout << str3 << std::endl; // 输出: Hello, C++!
// 获取子字符串
std::string sub = str3.substr(7, 3); // 从位置 7 开始,长度为 3
std::cout << "Substring: " << sub << std::endl; // 输出: C++
return 0;
}
4. 性能
效率:string 类在大多数情况下提供高效的字符串操作,尤其是在字符串拼接和查找时。
内存管理:通过使用动态数组,string 类能够有效地管理内存,减少不必要的复制和内存分配。
5. 注意事项
性能开销:虽然 string 类在大多数情况下表现良好,但在极端性能敏感的场景下,可能需要考虑使用 std::vector<char> 或 C 风格字符串。
多字节字符:string 类主要用于处理 ASCII 字符,对于 Unicode 字符串,建议使用 std::wstring 或其他专门的库。
总结
C++ 标准库中的 string 类是处理字符串的强大工具,提供了灵活的功能和自动化的内存管理。掌握 string 类的使用能够显著提高代码的可读性和可维护性。
底层实现
基本类型:std::string 实际上是 basic_string 模板类的一个特化,通常定义为:
typedef basic_string<char, std::char_traits<char>, std::allocator<char>> string;
basic_string 是一个模板类,可以特化为不同的字符类型(如 wchar_t 用于宽字符)。
4. 字符串的限制
单字节字符:std::string 主要用于处理单字节字符(如 ASCII 字符),不适合处理多字节字符或变长字符(如 UTF-8 编码的字符)。
Unicode 支持:对于需要处理 Unicode 字符或多字节字符的应用,建议使用 std::wstring(宽字符字符串)或其他专门的库(如 ICU 或 Boost.Locale)。
1. size() 与 length()
相同实现:size() 和 length() 方法在底层的实现原理完全相同,都是返回 std::string 中有效字符的数量。
一致性:引入 size() 是为了与其他 STL 容器(如 std::vector 和 std::list)的接口保持一致。一般情况下,推荐使用 size(),因为它在 STL 中更为常见。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello, World!";
std::cout << "Size: " << str.size() << std::endl; // 输出: Size: 13
std::cout << "Length: " << str.length() << std::endl; // 输出: Length: 13
return 0;
}
2. clear()
功能:clear() 方法会将字符串中的所有有效字符清空,使其长度变为 0,但不会释放底层的内存。
内存管理:这样做可以提高性能,因为在后续操作中,如果字符串再次被填充,可以避免频繁的内存分配和释放。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello, World!";
str.clear(); // 清空字符串
std::cout << "Size after clear: " << str.size() << std::endl; // 输出: Size after clear: 0
std::cout << "Capacity after clear: " << str.capacity() << std::endl; // 容量可能不变
return 0;
}
3. resize()
resize(size_t n):将字符串的有效字符个数调整为 n。如果 n 大于当前大小,新的字符将用 '\0'(空字符)填充。
resize(size_t n, char c):功能类似,但当字符个数增多时,使用字符 c 填充多出的元素。
容量变化:
如果增加字符个数,resize 可能会导致底层容量的增长,以容纳新增字符。
如果减少字符个数,底层空间的总大小不变,仍然保持了之前的分配。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
std::cout << "Capacity : " << str.capacity() << std::endl; // 容量
// 扩展到10个字符,使用'\0'填充
str.resize(10);
std::cout << "Resized to 10: " << str.size() << ", Content: ";
for (char c : str) {
if (c == '\0') {
std::cout << "\\0"; // 明确显示空字符
}
else {
std::cout << c;
}
}
std::cout << std::endl;
// 扩展到13个字符,使用'*'填充
str.resize(13, '*');
std::cout << "Resized to 13 with '*': " << str.size() << ", Content: " << str << std::endl; // 输出: Hello***
// 缩小到3个字符
str.resize(3);
std::cout << "Resized to 3: " << str.size() << ", Content: " << str << std::endl; // 输出: Hel
std::cout << "Capacity after resizing to 3: " << str.capacity() << std::endl; // 容量保持不变
return 0;
}
4. reserve()
预留空间:reserve(size_t res_arg=0) 方法用于为字符串预留至少 res_arg 个字符的空间,但不改变有效元素的个数。
容量管理:如果 res_arg 小于当前容量,reserve 不会改变底层容量。这有助于避免多次内存分配,特别是在预期字符串将增长的情况下。
#include <iostream>
#include <string>
int main() {
std::string str;
str.reserve(100); // 预留100个字符的空间
std::cout << "Size: " << str.size() << ", Capacity: " << str.capacity() << std::endl; // 输出: Size: 0, Capacity: 100或大一些>100
str = "Hello"; // 现在有效字符为5
std::cout << "Size after assignment: " << str.size() << ", Capacity: " << str.capacity() << std::endl; // 输出: Size: 5, Capacity: 100或大一些>100
str.reserve(50); // 小于当前容量,不会改变
std::cout << "Capacity after reserve(50): " << str.capacity() << std::endl; // 输出: 同上
str.reserve(200); // 大于当前容量,容量改变
std::cout << "Capacity after reserve(200): " << str.capacity() << std::endl; // 可能输出: 200 或大一些>200
return 0;
}
string类对象的访问及遍历
函数名称 | 功能说明 |
---|---|
operator[] | 返回 pos 位置的字符,可以用于 const std::string 对象调用。 |
begin() | 返回指向字符串第一个字符的迭代器。 |
end() | 返回指向字符串最后一个字符下一个位置的迭代器。 |
rbegin() | 返回指向字符串最后一个字符的逆向迭代器。 |
rend() | 返回指向字符串第一个字符前一个位置的逆向迭代器。 |
范围 for 循环 | C++11 引入的简洁语法,用于遍历字符串中的每个字符。 |
1. operator[]
- 功能:返回指定位置
pos
的字符。对于常量字符串对象,可以通过const std::string
调用此操作符。 - 示例:
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
char ch = str[1]; // 获取索引为1的字符
std::cout << "Character at position 1: " << ch << std::endl; // 输出: e
return 0;
}
2. begin()
和 end()
- 功能:
begin()
:返回指向字符串第一个字符的迭代器。end()
:返回指向字符串最后一个字符下一个位置的迭代器。
- 示例:
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 使用 begin() 和 end() 遍历字符串
for (auto it = str.begin(); it != str.end(); ++it) {
std::cout << "Character: " << *it << std::endl;
}
return 0;
}
3. rbegin()
和 rend()
- 功能:
rbegin()
:返回指向字符串最后一个字符的逆向迭代器。rend()
:返回指向字符串第一个字符前一个位置的逆向迭代器。
- 示例:
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 使用 rbegin() 和 rend() 遍历字符串
for (auto it = str.rbegin(); it != str.rend(); ++it) {
std::cout << "Character: " << *it << std::endl; // 输出: o, l, l, e, H
}
return 0;
}
4. 范围 for 循环 (C++11)
- 功能:C++11 引入的范围 for 循环可以更简洁地遍历字符串,直接遍历每个字符。
- 示例:
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 使用范围 for 循环遍历字符串
for (auto c : str) {
std::cout << "Character: " << c << std::endl; // 输出: H, e, l, l, o
}
return 0;
}
1. 使用下标运算符 []
你可以使用下标运算符来访问字符串中的单个字符。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 访问字符
for (size_t i = 0; i < str.size(); ++i) {
std::cout << "Character at index " << i << ": " << str[i] << std::endl;
}
return 0;
}
2. 使用 at()
方法
at()
方法提供了边界检查,如果索引超出范围,会抛出异常。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 使用 at() 方法访问字符
for (size_t i = 0; i < str.size(); ++i) {
std::cout << "Character at index " << i << ": " << str.at(i) << std::endl;
}
return 0;
}
3. 使用迭代器
可以使用迭代器来遍历字符串,这与遍历其他 STL 容器的方式一致。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 使用迭代器遍历字符串
for (std::string::iterator it = str.begin(); it != str.end(); ++it) {
std::cout << "Character: " << *it << std::endl;
}
return 0;
}
4. 使用 const 迭代器
如果不需要修改字符串,可以使用 const_iterator
。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 使用 const 迭代器遍历字符串
for (std::string::const_iterator it = str.cbegin(); it != str.cend(); ++it) {
std::cout << "Character: " << *it << std::endl;
}
return 0;
}
5. 使用 std::for_each
可以结合算法库中的 std::for_each
来遍历字符串。
#include <iostream>
#include <string>
#include <algorithm>
int main() {
std::string str = "Hello";
// 使用 std::for_each 遍历字符串
std::for_each(str.begin(), str.end(), [](char c) {
std::cout << "Character: " << c << std::endl;
});
return 0;
}
string类对象的修改操作
函数名称 | 功能说明 |
---|---|
push_back(c) | 在字符串后尾插入字符 c 。 |
append(str) | 在字符串后追加另一个字符串 str 。 |
operator+= | 在字符串后追加字符串 str ,与 append 类似。 |
c_str() | 返回一个指向 C 风格字符串的指针(即以 null 结尾的字符数组)。 |
find(c, pos) | 从字符串 pos 位置开始向后查找字符 c ,返回其位置,未找到则返回 std::string::npos 。 |
rfind(c, pos) | 从字符串 pos 位置开始向前查找字符 c ,返回其位置,未找到则返回 std::string::npos 。 |
substr(pos, n) | 从字符串 pos 位置开始,截取 n 个字符并返回新字符串。 |
1. push_back(c)
在字符串后尾插入字符 c
。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
str.push_back('!'); // 在字符串后追加字符 '!'
std::cout << "After push_back: " << str << std::endl; // 输出: Hello!
return 0;
}
2. append(str)
在字符串后追加另一个字符串 str
。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
str.append(" World"); // 追加字符串 " World"
std::cout << "After append: " << str << std::endl; // 输出: Hello World
return 0;
}
3. operator+=
在字符串后追加字符串 str
。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
str += " World"; // 使用 operator+= 追加字符串 " World"
std::cout << "After operator+=: " << str << std::endl; // 输出: Hello World
return 0;
}
4. c_str()
返回一个指向 C 风格字符串的指针。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
const char* cstr = str.c_str(); // 获取 C 风格字符串
std::cout << "C-style string: " << cstr << std::endl; // 输出: Hello
return 0;
}
5. find(c, pos)
从字符串 pos
位置开始向后查找字符 c
。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello, World!wllWL";
size_t pos = str.find('W',8); // 查找字符 'W'
if (pos != std::string::npos) {
std::cout << "'W' found at position: " << pos << std::endl; // 输出: 'W' found at position: 16
} else {
std::cout << "'W' not found" << std::endl;
}
return 0;
}
6. rfind(c, pos)
从字符串 pos
位置开始向前查找字符 c
。
#include <iostream>
#include <string>
int main() {
std::string str = "oppoHello, World!";
size_t pos = str.rfind('o', 11); // 从最后一个字符向前查找字符 'o',查找范围到索引 12(包括 12)
if (pos != std::string::npos) {
std::cout << "'o' found at position: " << pos << std::endl; // 输出找到的字符位置
}
else {
std::cout << "'o' not found" << std::endl;
}
return 0;
}
rfind 的工作原理
str.rfind('o', 11) 从字符串的索引 11 开始向前查找字符 'o'。
字符串 "oppoHello, World!" 的索引如下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
o p p o H e l l o , W o r l d !
结果:'o' found at position: 8
7. substr(pos, n)
从字符串 pos
位置开始,截取 n
个字符并返回新字符串。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello, World!";
std::string sub = str.substr(7, 5); // 从位置 7 开始截取 5 个字符
std::cout << "Substring: " << sub << std::endl; // 输出: World
return 0;
}
注意事项:
1. 字符串尾部追加字符的方法
在 std::string
中,追加字符有几种常用的方法:
push_back(c)
:在字符串末尾添加单个字符c
。append(1, c)
:在字符串末尾添加一个字符c
,通过append
方法指定要添加的字符数量。s += 'c'
:使用+=
操作符直接追加字符。
这三种方法在效果上几乎相同,但 +=
操作符更常用,因为它不仅可以用于追加单个字符,还可以用于追加字符串。
示例代码
#include <iostream>
#include <string>
int main() {
std::string str = "Hello";
// 使用 push_back
str.push_back('!'); // Hello!
// 使用 append
str.append(1, '?'); // Hello!?
// 使用 += 操作符
str += '.'; // Hello!?.
std::cout << "Final string: " << str << std::endl; // 输出: Hello!?.
return 0;
}
2. 预留空间
在对 std::string
进行大量操作时,如果能够预估大概需要多少字符,可以使用 reserve
方法预留足够的空间。这样可以减少内存分配的次数,从而提高性能。
示例代码
#include <iostream>
#include <string>
int main() {
std::string str;
str.reserve(50); // 预留 50 个字符的空间
// 假设我们将要添加大量字符
for (int i = 0; i < 25; ++i) {
str += 'A'; // 追加字符
}
std::cout << "String size: " << str.size() << ", Capacity: " << str.capacity() << std::endl;
return 0;
}
输出结果
在这个示例中,如果你预留了 50 个字符的空间,最终的输出可能会是:
String size: 25, Capacity: 一般大于预留空间50
虽然你请求了 50 个字符的空间,但实际的容量可能是 63。这是因为:
内存对齐:按照实现的对齐要求,可能会选择下一个合适的边界。
预留空间策略:为了在进行后续操作时减少内存分配,std::string 可能会分配更多的空间。
总结
- 使用
push_back
、append
和+=
操作符可以方便地在字符串尾部追加字符,但+=
操作符更为常用。 - 使用
reserve
方法预留空间可以提高性能,尤其是在处理大量字符串操作时。
string类非成员函数
函数名称 | 功能说明 |
---|---|
operator+ | 尽量少用,因为会导致深拷贝,效率较低。 |
operator>> | 输入运算符重载,用于从输入流中读取字符串。 |
operator<< | 输出运算符重载,用于向输出流写入字符串。 |
getline | 获取一行字符串,直到遇到换行符为止。 |
关系运算符 (< , > , <= , >= , == , != ) | 用于字符串之间的大小比较,支持 lexicographical(字典序)比较。 |
1. operator+
尽量少用,因为会导致深拷贝,效率较低。
#include <iostream>
#include <string>
int main() {
std::string str1 = "Hello";
std::string str2 = " World!";
// 使用 operator+ 连接字符串
std::string result = str1 + str2; // 可能导致深拷贝
std::cout << "Concatenated string: " << result << std::endl; // 输出: Hello World!
return 0;
}
2. operator>>
输入运算符重载,用于从输入流中读取字符串。
#include <iostream>
#include <string>
int main() {
std::string str;
std::cout << "Enter a string: ";
std::cin >> str; // 使用 operator>> 输入字符串
std::cout << "You entered: " << str << std::endl;
return 0;
}
3. operator<<
输出运算符重载,用于向输出流写入字符串。
#include <iostream>
#include <string>
int main() {
std::string str = "Hello, World!";
std::cout << "The string is: " << str << std::endl; // 使用 operator<< 输出字符串
return 0;
}
4. getline
获取一行字符串,直到遇到换行符为止。
#include <iostream>
#include <string>
int main() {
std::string line;
std::cout << "Enter a line of text: ";
std::getline(std::cin, line); // 使用 getline 获取整行
std::cout << "You entered: " << line << std::endl;
return 0;
}
5. 关系运算符
用于字符串之间的大小比较,支持字典序比较。
#include <iostream>
#include <string>
int main() {
std::string str1 = "apple";
std::string str2 = "banana";
if (str1 < str2) {
std::cout << str1 << " is less than " << str2 << std::endl; // 输出: apple is less than banana
} else {
std::cout << str1 << " is not less than " << str2 << std::endl;
}
return 0;
}
6. 字符串分割
#include <iostream>
#include <cstring>
void splitString(const char* str, char delimiter) {
char* token = strtok(const_cast<char*>(str), &delimiter);
while (token) {
std::cout << token << std::endl;
token = strtok(nullptr, &delimiter);
}
}
int main() {
char str[] = "apple,banana,cherry";
splitString(str, ',');
return 0;
}
深拷贝和浅拷贝
深拷贝和浅拷贝是内存管理中两个重要的概念,特别是在处理对象和资源时。下面是对这两个概念的详细解释以及它们之间的区别。
1. 深拷贝(Deep Copy)
深拷贝指的是创建一个对象的完全独立副本。对于对象中的指针成员,深拷贝将会分配新的内存空间,并将原对象所指向的内容复制到新空间中。这样,新对象和原对象之间就没有共享任何数据。
2. 浅拷贝(Shallow Copy)
浅拷贝指的是创建一个对象的副本,但是对于指针成员,它只复制指针的值,而不分配新的内存空间。这意味着原对象和新对象将共享同一块内存。如果一个对象被销毁,另一个对象中的指针将指向已释放的内存,导致未定义行为。
3. 深拷贝与浅拷贝的区别
特性 | 深拷贝 | 浅拷贝 |
---|---|---|
内存分配 | 为每个对象分配独立的内存 | 共享同一内存 |
数据独立性 | 每个对象的数据独立 | 对象之间的数据是共享的 |
复杂性 | 实现较复杂,需要手动管理资源 | 实现简单,通常不需要额外管理 |
安全性 | 更安全,避免了悬挂指针等问题 | 不安全,容易出现内存错误 |
4. 效率比较
- 效率低:深拷贝通常效率较低,因为它涉及到内存分配和数据复制,这在资源消耗上较大。
- 效率高:浅拷贝效率较高,因为它只复制指针,不涉及数据的实际复制。
总结
- 深拷贝和浅拷贝的主要区别在于内存管理和数据共享。
- 深拷贝一般更安全,但效率较低;浅拷贝效率高,但可能导致内存错误和未定义行为。选择使用哪种拷贝方式取决于具体的应用场景和需求。
vs和g++下string结构的说明
在 32 位平台上,std::string
的内部结构和内存管理在不同的编译器中可能会有所不同。以下是 Visual Studio (MSVC) 下 std::string
的具体结构说明,尤其是在 32 位平台上。
Visual Studio 下 std::string
的结构
- 总大小:在 32 位平台上,
std::string
的总大小通常为 28 字节。 - 内部结构:
std::string
使用一个联合体来管理字符串的存储,具体如下:
1. 联合体(Union)
联合体用于定义字符串的存储方式。主要有两个部分:
- 固定大小的字符数组(当字符串长度小于 16 时):
- 它是一个内部的字符数组来存储小字符串。
- 指向堆内存的指针(当字符串长度大于等于 16 时):
- 当字符串长度达到或超过 16 字节时,
std::string
会动态分配内存,并使用指针来引用这块内存。
- 当字符串长度达到或超过 16 字节时,
2. 具体成员
在 32 位平台上,std::string
结构的可能成员包括:
- 指针:指向堆内存的指针(4 字节)。
- 长度:当前字符串的长度(4 字节)。
- 容量:当前分配的内存容量(4 字节)。
- 小字符串数组:用于存储小于 16 字节的字符串(16 字节)。
内存布局示例
成员 | 大小 (字节) | 描述 |
---|---|---|
小字符串数组 | 16 | 存储小于 16 字节的字符串 |
当前长度 | 4 | 当前字符串的长度 |
当前容量 | 4 | 当前分配的内存容量 |
指针 | 4 | 指向堆内存的指针(当字符串长度 >= 16 时) |
- 总大小:在 32 位平台上,
std::string
总共占 28 个字节。
union _Bxty {
// storage for small buffer or pointer to larger one
value_type _Buf[_BUF_SIZE]; // 用于存储小字符串的固定大小数组
pointer _Ptr; // 指向堆上分配的内存
char _Alias[_BUF_SIZE]; // 允许别名化的字符数组
} _Bx;
设计理由
- 效率:这个设计允许在大多数情况下(字符串长度小于 16 字节)避免动态内存分配,从而提高性能。
- 减少内存分配:对于短字符串,直接在对象内部使用固定大小数组,避免了堆内存分配的开销。
- 灵活性:对于长字符串,使用堆内存提供了灵活的存储选项,而不会增加内存的碎片化。
g++下string的结构
在 GCC(g++)中,采用了写时拷贝(copy-on-write, COW)技术来优化内存管理和减少不必要的复制。
std::string
的内部结构
std::string
的内部结构通常包含以下几个关键部分:
struct _Rep_base {
size_type _M_length; // 字符串有效长度
size_type _M_capacity; // 空间总大小
_Atomic_word _M_refcount; // 引用计数
};
结构解释
_M_length
: 存储当前字符串的实际长度,不包括结束符\0
。_M_capacity
: 存储分配的总内存大小,可以容纳的字符数(包括结束符)。_M_refcount
: 用于支持写时拷贝的引用计数,表示有多少个std::string
实例共享同一块内存。- 指针:
std::string
实际上只占用固定的内存(通常是 4 或 8 个字节),指向堆内存中存储字符串的区域。
写时拷贝(COW)
- 共享数据: 当一个
std::string
被复制时,实际的数据并不会立即复制,两个字符串实例会共享同一块内存。只有在其中一个字符串被修改时,才会进行真正的复制(即写时拷贝)。 - 性能优势: 这种方式减少了不必要的内存分配和复制,提高了性能,特别是在处理大量字符串时。
注意事项
- 写时拷贝的实现依赖于底层的原子操作,确保多线程环境下的安全性。
- 随着 C++11 的发展,许多编译器逐渐去除写时拷贝的实现,转而使用更简单的方式(例如小字符串优化)。
总结
std::string
的实现是一个复杂的过程,旨在平衡性能和内存管理,通过写时拷贝等技术提供高效的字符串处理能力。
string类的模拟实现
浅拷贝
浅拷贝是指在复制对象时,只复制对象的成员变量的值,并不复制它们所指向的资源(如动态分配的内存)。这意味着多个对象会共享同一块内存地址。如果一个对象被销毁或其资源被释放,其他对象仍然持有指向该内存的指针,这会导致访问已释放内存的风险。
特点
- 共享内存:多个对象指向同一块内存。
- 内存管理问题:一个对象的析构函数释放了内存,其他对象仍然可以访问这块内存,可能导致未定义行为。
- 效率:浅拷贝通常比深拷贝更高效,因为不需要额外的内存分配和数据复制。
深拷贝
深拷贝是一种对象复制方式,它不仅复制对象的成员变量的值,还复制它们所指向的资源(如动态分配的内存)。深拷贝确保每个对象都有自己的独立副本,从而避免多个对象共享同一资源引发的内存管理问题。
特点
- 独立内存:每个对象拥有自己的一份资源副本。
- 安全性:即使一个对象被析构,其他对象仍然可以安全地访问自己的数据。
- 性能开销:深拷贝通常比浅拷贝慢,因为需要分配新的内存并复制数据。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <cstring> // 用于 C 风格字符串操作
namespace test
{
class string
{
private:
char* _str; // 指向动态分配的字符数组
size_t _size; // 字符串当前长度
size_t _capacity; // 字符串分配的容量
// 初始化私有方法,分配内存并复制字符串内容
void init(const char* str)
{
_size = strlen(str); // 计算字符串长度
_capacity = _size; // 初始化容量为当前长度
_str = new char[_capacity + 1]; // 分配内存,包括结束符'\0'
strcpy(_str, str); // 复制字符串内容
}
public:
typedef char* iterator; // 定义迭代器类型
// 返回字符串的起始迭代器
iterator begin()
{
return _str;
}
// 返回字符串的结束迭代器
iterator end()
{
return _str + _size;
}
// 默认构造函数,允许使用空字符串初始化
string(const char* str = "")
{
init(str); // 调用初始化方法
}
// 拷贝构造函数
string(const string& s)
{
init(s._str); // 复制另一个字符串的内容
}
// 移动构造函数
string(string&& s) noexcept
: _str(s._str), _size(s._size), _capacity(s._capacity)
{
// 转移资源所有权
s._str = nullptr; // 使原对象的指针无效
s._size = s._capacity = 0; // 重置原对象的大小和容量
}
// 赋值运算符
string& operator=(const string& s)
{
if (this != &s) { // 自我赋值检查防止自赋值
char* tmp = new char[s._size + 1]; // 分配新内存
strcpy(tmp, s._str); // 复制内容
delete[] _str; // 释放旧内存
_str = tmp; // 更新指针
_size = s._size; // 更新大小
_capacity = s._capacity; // 更新容量
}
return *this; // 返回对象自身
}
// 移动赋值运算符
string& operator=(string&& s) noexcept
{
if (this != &s) { // 自我赋值检查
delete[] _str; // 释放旧内存
_str = s._str; // 接管资源
_size = s._size; // 更新大小
_capacity = s._capacity; // 更新容量
s._str = nullptr; // 使原对象的指针无效
s._size = s._capacity = 0; // 重置原对象
}
return *this; // 返回对象自身
}
// 析构函数,释放内存
~string()
{
delete[] _str; // 释放动态分配的内存
}
// 返回当前字符串长度
size_t size() const
{
return _size; // 返回大小
}
// 返回当前容量
size_t capacity() const
{
return _capacity; // 返回容量
}
// 重载下标运算符,提供字符访问
char& operator[](size_t i)
{
return _str[i]; // 返回指定索引的字符
}
// 返回 C 风格字符串
const char* c_str() const
{
return _str; // 返回字符串内容
}
// 在字符串末尾添加一个字符
void push_back(char ch)
{
if (_size == _capacity) { // 检查是否需要扩容
reserve(_capacity == 0 ? 2 : _capacity * 2); // 申请更多内存
}
_str[_size++] = ch; // 添加字符并更新大小
_str[_size] = '\0'; // 确保字符串结束符
}
// 在字符串末尾添加一个 C 风格字符串
void append(const char* str)
{
size_t len = strlen(str); // 获取待添加字符串的长度
if (_size + len > _capacity) { // 检查是否需要扩容
reserve((_size + len) * 2); // 申请更多内存
}
strcpy(_str + _size, str); // 复制新字符串到末尾
_size += len; // 更新大小
}
// 预留空间以容纳更多字符
void reserve(size_t n)
{
if (n > _capacity) { // 只在需要时扩容
char* newstr = new char[n + 1]; // 分配新内存
strcpy(newstr, _str); // 复制旧字符串
delete[] _str; // 释放旧内存
_str = newstr; // 更新指针
_capacity = n; // 更新容量
}
}
// 重载 += 运算符,添加单个字符
string& operator+=(char ch)
{
push_back(ch); // 调用 push_back 方法
return *this; // 返回对象自身
}
// 重载 += 运算符,添加 C 风格字符串
string& operator+=(const char* str)
{
append(str); // 调用 append 方法
return *this; // 返回对象自身
}
// 在指定位置插入单个字符
string& insert(size_t pos, char ch)
{
if (pos > _size || pos < 0) // 检查位置有效性
{
return *this; // 位置无效,返回当前对象
}
if (_size == _capacity) // 检查是否需要扩容
{
reserve(_capacity == 0 ? 2 : _capacity * 2); // 申请更多内存
}
// 向后移动字符以腾出插入位置
for (int end = _size; end >= (int)pos; --end)
{
_str[end + 1] = _str[end];
}
_str[pos] = ch; // 插入字符
++_size; // 更新大小
_str[_size] = '\0'; // 确保字符串以 null 终止
return *this; // 返回对象自身
}
// 在指定位置插入 C 风格字符串
string& insert(size_t pos, const char* str)
{
if (pos > _size || pos < 0) // 检查位置有效性
{
return *this; // 位置无效,返回当前对象
}
size_t len = strlen(str); // 获取待插入字符串长度
if (_size + len >= _capacity) // 检查是否需要扩容
{
reserve(_capacity == 0 ? 2 : _capacity + _size + len); // 申请更多内存
}
// 向后移动字符以腾出插入位置
for (int end = _size; end >= (int)pos; --end)
{
_str[end + len] = _str[end]; // 移动字符
}
// 复制插入的字符串
for (size_t i = 0; i < len; ++i)
{
_str[pos + i] = str[i]; // 插入字符
}
_size += len; // 更新大小
_str[_size] = '\0'; // 确保字符串以 null 终止
return *this; // 返回对象自身
}
// 调整字符串大小
void resize(size_t n, char ch = '\0')
{
if (n < _size) // 缩小大小
{
_str[n] = '\0'; // 确保字符串以 null 终止
_size = n; // 更新大小
}
else // 扩大大小
{
if (n >= _capacity) // 检查是否需要扩容
{
reserve(n + 1); // 申请更多内存
}
// 填充新字符
for (size_t i = _size; i < n; ++i)
{
_str[i] = ch; // 填充字符
}
_size = n; // 更新大小
_str[_size] = '\0'; // 确保字符串以 null 终止
}
}
// 删除指定位置的字符
string& erase(size_t pos, size_t len = std::string::npos)
{
if (len >= _size - pos) // 如果要删除的长度超过剩余长度
{
_str[pos] = '\0'; // 截断字符串
_size = pos; // 更新大小
}
else // 删除指定长度的字符
{
size_t i = pos + len; // 计算要移动的字符起始位置
while (i <= _size) // 移动字符以填补空缺
{
_str[i - len] = _str[i];
++i;
}
_size -= len; // 更新大小
_str[_size] = '\0'; // 确保字符串以 null 终止
}
return *this; // 返回对象自身
}
// 查找字符在字符串中的位置
size_t find(char ch, size_t pos = 0) const
{
for (size_t i = pos; i < _size; ++i)
{
if (_str[i] == ch) // 如果找到字符
{
return i; // 返回位置
}
}
return -1; // 未找到
}
// 查找 C 风格字符串在字符串中的位置
size_t find(const char* str, size_t pos = 0) const
{
char* p = strstr(_str + pos, str); // 从指定位置查找
if (p == nullptr) // 如果未找到
{
return -1; // 返回 -1
}
else
{
return p - _str; // 返回找到的位置
}
}
};
}
// 打印字符串内容的辅助函数
void print(test::string& s) {
for (size_t i = 0; i < s.size(); i++) {
std::cout << s[i]; // 逐个字符输出
}
std::cout << std::endl; // 换行
}
int main() {
test::string s1("hello world"); // 创建字符串
test::string s2(s1); // 拷贝构造
test::string s3; // 创建空字符串
s3 = s1; // 赋值操作
print(s1); // 打印 s1
// 修改 s1 的每个字符
for (size_t i = 0; i < s1.size(); i++) {
s1[i] += 1; // 每个字符加 1
}
print(s1); // 打印修改后的 s1
// 打印 C 风格字符串
std::cout << s1.c_str() << std::endl;
std::cout << s2.c_str() << std::endl;
std::cout << s3.c_str() << std::endl;
// 使用迭代器遍历 s1
test::string::iterator it = s1.begin();
while (it != s1.end()) {
*it -= 1; // 每个字符减 1
std::cout << *it << " "; // 打印字符
it++; // 移动到下一个字符
}
std::cout << std::endl;
// 使用范围 for 循环打印 s1
for (auto e : s1) {
std::cout << e << " "; // 打印每个字符
}
std::cout << std::endl;
// 向 s1 添加字符
s1.push_back(' ');
s1.push_back('q');
s1.push_back('e');
s1.push_back('t');
s1.push_back('u');
print(s1); // 打印修改后的 s1
// 创建另一个字符串并使用 += 操作符
test::string s4;
s4 += "bbbb"; // 添加字符串
print(s4);
s4 += 'v'; // 添加单个字符
print(s4);
s4.insert(0, 'r'); // 在开头插入字符
std::cout << s4.size() << " " << s4.capacity() << std::endl;
s4.insert(3, "cbb"); // 在指定位置插入字符串
print(s4);
std::cout << s4.size() << " " << s4.capacity() << std::endl;
s4.resize(15, 'w'); // 调整大小并填充字符
print(s4);
std::cout << s4.size() << " " << s4.capacity() << std::endl;
s4.erase(8, 3); // 删除指定位置的字符
print(s4);
std::cout << s4.find("cbb") << std::endl; // 查找并打印位置
std::cout << s4.find("cbbx") << std::endl; // 查找未找到的字符串
return 0; // 结束程序
}
写时拷贝 (Copy-on-Write, COW)
写时拷贝是一种优化技术,主要用于延迟复制对象,直到需要修改时才进行真正的拷贝。这种策略可以减少不必要的内存使用和复制操作,提高性能,特别是在处理大量只读数据时。
工作原理
- 共享数据:当一个对象被复制时,原对象和新对象共享同一份数据。
- 标记状态:每个对象维护一个引用计数,记录有多少个对象共享这份数据。
- 拷贝时复制:当某个对象尝试修改共享数据时,系统会检查引用计数。如果引用计数大于1,系统会创建数据的独立副本,然后进行修改。当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象为资源的最后一个使用者,就将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。
vector
C++ 中的 std::vector
是一个动态数组,可以存储任意数量的元素。它是 STL(标准模板库)中一个非常重要的容器,定义在 <vector> 头文件中。
#include <vector>
std::vector<T>
T
是存储在vector
中的元素类型。可以是基本数据类型、自定义类型、指针等。
具有以下特点和功能:
基本特性
- 动态大小:
vector
可以在运行时动态调整大小,支持添加和删除元素。 - 连续存储:元素在内存中是连续存储的,这使得访问速度快。
- 支持随机访问:可以通过下标直接访问元素,时间复杂度为 O(1)。
- 自动管理内存:
vector
会自动管理内存,使用时无需手动分配或释放内存。
常用操作
-
创建和初始化:
#include <vector> std::vector<int> v1; // 创建空的 vector std::vector<int> v2(10); // 创建包含 10 个默认值的 vector std::vector<int> v3{1, 2, 3, 4, 5}; // 使用初始化列表
-
添加元素:
v1.push_back(10); // 在末尾添加元素 v1.push_back(20);
-
访问和修改元素:
int first = v1[0]; // 访问第一个元素 v1[1] = 30; // 修改第二个元素
-
删除元素:
v1.pop_back(); // 删除末尾元素 v1.erase(v1.begin()); // 删除第一个元素 v1.clear(); // 清空所有元素
-
大小和容量:
size_t size = v1.size(); // 当前元素数量 size_t capacity = v1.capacity(); // 当前分配的容量
迭代器
vector
支持迭代器,可以使用范围 for
循环、标准迭代器等:
for (int value : v1) {
// 处理 value
}
常用成员函数
begin()
和end()
:返回指向第一个和最后一个元素的迭代器。insert()
:在指定位置插入元素。resize()
:调整容器大小。swap()
:交换两个 vector 的内容。
示例代码
以下是一个简单的示例,展示了 vector
的基本用法:
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers;
// 添加元素
numbers.push_back(1);
numbers.push_back(2);
numbers.push_back(3);
// 输出元素
for (int num : numbers) {
std::cout << num << " ";
}
std::cout << std::endl;
// 删除元素
numbers.pop_back(); // 删除最后一个元素
// 输出剩余元素
for (int num : numbers) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
迭代器失效
迭代器失效是指在使用迭代器遍历容器(如数组、链表、集合等)时,迭代器的有效性受到影响,可能导致运行时错误或未定义行为。这个问题通常发生在对容器进行修改操作时,比如插入、删除或重排元素。以下是对迭代器失效的详细解释:
常见原因
-
删除操作:
- 在容器中删除元素后,所有指向该元素的迭代器将失效。
- 例如,在 STL 的
std::vector
中,删除一个元素可能导致后面的元素移动,从而使得原有的迭代器失效。
-
插入操作:
在某些容器(如std::vector
)中,插入元素可能导致容器重新分配内存,这会使得所有指向原始数据的迭代器失效。 -
重排操作:
对容器进行排序或其他重排操作,可能会导致迭代器失效,因为元素的位置发生了变化。
迭代器的主要作用就是让算法能够不用关心底层数据结构,其底层实际就是一个指针,或者是对指针进行了封装,比如:vector的迭代器就是原生态指针T* 。因此迭代器失效,实际就是迭代器底层对应指针所指向的空间被销毁了,而使用一块已经被释放的空间,造成的后果是程序崩溃(即如果继续使用已经失效的迭代器, 程序可能会崩溃)。
可能会导致其迭代器失效的操作有:
1. 会引起其底层空间改变的操作,都有可能是迭代器失效,比如:resize、reserve、insert、assign、 push_back等。
2. 指定位置元素的删除操作:erase
erase删除pos位置元素后,pos位置之后的元素会往前搬移,没有导致底层空间的改变,理论上讲迭代 器不应该会失效,但是:如果pos刚好是最后一个元素,删完之后pos刚好是end的位置,而end位置是 没有元素的,那么pos就失效了。
迭代器失效解决办法:在使用前,对迭代器重新赋值即可。
#include <iostream>
using namespace std;
#include <vector>
int main()
{
vector<int> v{1,2,3,4,5,6};
auto it = v.begin();
// 将有效元素个数增加到100个,多出的位置使用8填充,操作期间底层会扩容
// v.resize(100, 8);
// reserve的作用就是改变扩容大小但不改变有效元素个数,操作期间可能会引起底层容量改变
// v.reserve(100);
// 插入元素期间,可能会引起扩容,而导致原空间被释放
// v.insert(v.begin(), 0);
// v.push_back(8);
// 给vector重新赋值,可能会引起底层容量改变
v.assign(100, 8);
/*
出错原因:以上操作,都有可能会导致vector扩容,也就是说vector底层原理旧空间被释放掉,
而在打印时,it还使用的是释放之间的旧空间,在对it迭代器操作时,实际操作的是一块已经被释放的
空间,而引起代码运行时崩溃。
解决方式:在以上操作完成之后,如果想要继续通过迭代器操作vector中的元素,只需给it重新
赋值即可。
*/
while(it != v.end())
{
cout<< *it << " " ;
++it;
}
cout<<endl;
return 0;
}
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 测试 vector 的各种操作
void test_vector()
{
vector<int> v1; // 创建一个空的整数 vector
// 向 vector 中添加元素
v1.push_back(7);
v1.push_back(8);
v1.push_back(36);
v1.push_back(9);
v1.push_back(0);
v1.push_back(3);
// 使用 v1 初始化 v2,v2 现在包含 v1 的所有元素
vector<int> v2(v1);
v2[3] = 6; // 修改 v2 中的第 4 个元素为 6
// 输出 v2 的所有元素
for (size_t i = 0; i < v2.size(); ++i)
{
cout << v2[i] << " ";
}
cout << endl;
// 使用迭代器遍历 v2
vector<int>::iterator it = v2.begin();
while (it != v2.end())
{
cout << *it << " "; // 输出当前迭代器指向的元素
++it; // 移动到下一个元素
}
cout << endl;
// 使用范围基于的 for 循环遍历 v2
for (auto e : v2)
{
cout << e << " "; // 输出每个元素
}
cout << endl;
// 使用反向迭代器遍历 v2
auto rit = v2.rbegin(); // 获取反向迭代器
while (rit != v2.rend())
{
cout << *rit << " "; // 输出当前反向迭代器指向的元素
++rit; // 移动到前一个元素
}
cout << endl;
// 输出 v2 的大小和容量
cout << v2.size() << endl; // 当前元素数量
cout << v2.capacity() << endl; // 当前分配的内存容量
// 在 v2 的开头插入元素 2
v2.insert(v2.begin(), 2);
for (auto e : v2)
{
cout << e << " "; // 输出新元素后的 v2
}
cout << endl;
// 删除 v2 的第一个元素
v2.erase(v2.begin());
for (auto e : v2)
{
cout << e << " "; // 输出删除后的 v2
}
cout << endl;
// 删除 v2 的第二个元素
v2.erase(v2.begin() + 1);
for (auto e : v2)
{
cout << e << " "; // 输出删除后的 v2
}
cout << endl;
// 查找元素 5,并删除
vector<int>::iterator pos = find(v2.begin(), v2.end(), 5);
if (pos != v2.end()) // 如果找到元素 5
{
v2.erase(pos); // 删除该元素
}
for (auto e : v2)
{
cout << e << " "; // 输出缺少元素 5 后的 v2
}
cout << endl;
// 对 v2 进行排序
sort(v2.begin(), v2.end());
for (auto e : v2)
{
cout << e << " "; // 输出排序后的 v2
}
cout << endl;
// 创建另一个 vector v,添加元素
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(5);
v.push_back(6);
v.push_back(8);
v.push_back(11);
// 使用迭代器遍历 v
vector<int>::iterator itq = v.begin();
// v.push_back(6); // 如果在此处扩容,itq 可能会失效
// v.push_back(7);
while (itq != v.end())
{
cout << *itq << " "; // 输出当前迭代器指向的元素
itq++;
}
cout << endl;
// 使用迭代器删除所有偶数元素
itq = v.begin();
while (itq != v.end())
{
if (*itq % 2 == 0) // 如果当前元素是偶数
{
itq = v.erase(itq); // 删除该元素,并更新迭代器为下一个位置
}
else
{
itq++; // 仅移动到下一个元素
}
}
cout << endl;
// 输出删除偶数后的 v
for (auto e : v)
{
cout << e << " "; // 输出剩余的奇数元素
}
cout << endl;
}
int main()
{
test_vector(); // 调用测试函数
return 0;
}
vector深度剖析及模拟实现
#include <iostream>
#include <algorithm> // 用于 std::copy 和 std::copy_backward
#include <cstring> // 用于 std::memcpy
#include <string>
using namespace std;
// 自定义动态数组类
template<class T>
class vector {
public:
typedef T* iterator; // 迭代器类型
typedef const T* const_iterator; // 常量迭代器类型
// 构造函数,初始化成员变量
vector() : _start(nullptr), _finish(nullptr), _endofstorage(nullptr) { }
// 拷贝构造函数
vector(const vector<T>& v)
: _start(new T[v.capacity()]), _finish(_start + v.size()), _endofstorage(_start + v.capacity())
{
std::copy(v._start, v._finish, _start); // 复制元素
}
// 赋值运算符
vector<T>& operator=(const vector<T>& v)
{
if (this != &v) {
vector<T> temp(v); // 创建临时对象
swap(temp); // 交换内部状态
}
return *this;
}
// 析构函数
~vector()
{
delete[] _start; // 释放动态分配的内存
}
// 返回迭代器的起始位置
iterator begin() { return _start; }
// 返回迭代器的结束位置
iterator end() { return _finish; }
// 返回常量迭代器的起始位置
const_iterator begin() const { return _start; }
// 返回常量迭代器的结束位置
const_iterator end() const { return _finish; }
// 预留空间
void reserve(size_t n)
{
if (n > capacity()) // 只有在新容量大于当前容量时才进行扩展
{
size_t sz = size();
T* tmp = new T[n]; // 分配新内存
if (_start)
{
std::copy(_start, _finish, tmp); // 复制旧数据
delete[] _start; // 释放旧内存
}
_start = tmp; // 更新起始指针
_finish = _start + sz; // 更新当前元素结束指针
_endofstorage = _start + n; // 更新存储结束指针
}
}
// 添加元素到末尾
void push_back(const T& x)
{
if (_finish == _endofstorage) // 检查是否需要扩展容量
{
size_t newcapacity = capacity() == 0 ? 2 : capacity() * 2; // 新容量为当前容量的两倍
reserve(newcapacity); // 预留新空间
}
*_finish = x; // 添加新元素
++_finish; // 更新结束位置
}
// 移除末尾元素
void pop_back()
{
if (_finish > _start) // 确保不为空
{
--_finish; // 更新结束位置
}
}
// 在指定位置插入元素
void insert(iterator pos, const T& x)
{
if (_finish == _endofstorage) // 检查是否需要扩展容量
{
size_t n = pos - _start; // 计算插入位置
reserve(capacity() == 0 ? 2 : capacity() * 2); // 预留新空间
pos = _start + n; // 更新插入位置
}
if (pos <= _finish) // 确保插入位置有效
{
std::copy_backward(pos, _finish, _finish + 1); // 移动元素
*pos = x; // 插入新元素
++_finish; // 更新结束位置
}
}
// 调整大小
void resize(size_t n, const T& val = T())
{
if (n < size()) // 减小大小
{
_finish = _start + n; // 更新结束位置
}
else {
if (n > capacity()) // 检查是否需要扩展容量
{
reserve(n); // 预留新空间
}
std::fill(_finish, _start + n, val); // 填充新元素
_finish = _start + n; // 更新结束位置
}
}
// 返回当前大小
size_t size() const { return _finish - _start; }
// 返回当前容量
size_t capacity() const { return _endofstorage - _start; }
// 重载下标运算符
T& operator[](size_t i) { return _start[i]; }
const T& operator[](size_t i) const { return _start[i]; }
// 删除指定位置的元素
iterator erase(iterator pos)
{
if (pos < _finish) // 确保位置有效
{
std::copy(pos + 1, _finish, pos); // 移动元素
--_finish; // 更新结束位置
return pos; // 返回删除后位置
}
return pos; // 返回原位置
}
// 交换函数
void swap(vector<T>& other) noexcept {
std::swap(_start, other._start);
std::swap(_finish, other._finish);
std::swap(_endofstorage, other._endofstorage);
}
private:
iterator _start; // 指向数据起始位置
iterator _finish; // 指向当前元素结束位置
iterator _endofstorage; // 指向可用存储结束位置
};
// 打印向量元素
void print_vector(const vector<int>& v)
{
for (auto it = v.begin(); it != v.end(); ++it)
{
cout << *it << " "; // 打印每个元素
}
cout << endl;
}
// 测试动态数组的功能
void test_vector()
{
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
cout << "Size: " << v.size() << endl;
cout << "Capacity: " << v.capacity() << endl;
v.push_back(4);
v.push_back(5);
cout << "Size: " << v.size() << endl;
cout << "Capacity: " << v.capacity() << endl;
print_vector(v);
v.insert(v.begin() + 2, 9); // 在索引2处插入9
print_vector(v);
v.insert(v.end(), 6); // 在末尾插入6
print_vector(v);
// 删除偶数
for (auto it = v.begin(); it != v.end(); )
{
if (*it % 2 == 0) // 检查是否为偶数
{
it = v.erase(it); // 删除并更新迭代器
}
else {
++it;
}
}
print_vector(v);
vector<int> v1(v); // 使用拷贝构造函数
print_vector(v1);
vector<int> v2;
v2 = v; // 使用赋值运算符
print_vector(v2);
vector<string> v3;
v3.push_back("1111111111111111111");
v3.push_back("2222222222222222222");
v3.push_back("3333333333333333333");
for (const auto& e : v3)
{
cout << e << " "; // 打印字符串元素
}
cout << endl;
}
int main()
{
test_vector(); // 测试动态数组功能
int i = 1;
cout << i << endl; // 打印整数
return 0;
}
使用memcpy拷贝问题
使用 memcpy
拷贝时,存在一些潜在的问题,尤其是在处理非平凡类型(如具有构造函数、析构函数或复制构造函数的类)时。
使用 memcpy
的问题
-
类型安全:
memcpy
只按字节拷贝内存,不考虑对象的类型。如果对象是非平凡类型,可能会导致未定义行为。 -
构造和析构:
memcpy
不会调用对象的构造函数或析构函数,这可能导致资源泄漏(如动态分配的内存)。 -
浅拷贝问题:
对于包含指针的类,使用memcpy
只会拷贝指针的值,而不是指针指向的实际内容。这会导致多个对象共享同一内存,进而引发悬挂指针或双重释放的问题。 -
不支持多态:
如果使用memcpy
拷贝基类指针,派生类的数据不会被正确拷贝,导致数据不一致。
替代方案
-
使用
std::copy
:std::copy
可以安全地处理非平凡类型,并确保调用构造函数和析构函数。
std::copy(source.begin(), source.end(), destination.begin());
-
使用
std::vector
:- 如果适用,可以使用标准库中的
std::vector
代替自定义的动态数组,std::vector
已经处理了所有内存管理和类型安全的问题。
- 如果适用,可以使用标准库中的
-
实现自定义拷贝逻辑:
- 在类中实现复制构造函数和赋值运算符,以确保正确处理资源和深拷贝。例如:
MyClass(const MyClass& other) { // 深拷贝逻辑 } MyClass& operator=(const MyClass& other) { if (this != &other) { // 释放旧资源 // 分配新资源并复制 } return *this; }
如果对象中涉及到资源管理时,不能使用memcpy进行对象之间的拷贝,因为memcpy是浅拷贝,可能会引起内存泄漏甚至程序崩溃。
动态二维数组
#include <iostream>
#include <vector>
int main() {
int rows = 3, cols = 4;
// 创建一个二维向量
std::vector<std::vector<int>> array(rows, std::vector<int>(cols));
// 使用数组
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
array[i][j] = i * cols + j; // 填充数据
std::cout << array[i][j] << " ";
}
std::cout << std::endl;
}
return 0; // 不需要手动释放内存
}
std::vector<std::vector<int>> array(n);
创建一个包含n
个std::vector<int>
的数组。
list
在 C++ 中,list
是标准模板库(STL)中提供的一种双向链表容器。与其他容器(如 vector
和 deque
)相比,list
在插入和删除元素时更为高效,尤其是在中间位置操作时。
1. 基本用法
要使用 list
,首先需要包含头文件:
#include <iostream>
#include <list>
2. 创建 list
可以使用多种方式创建 list
:
std::list<int> myList; // 创建一个空的整数链表
std::list<int> myList2(5, 10); // 创建一个包含 5 个元素,值为 10 的链表
3. 常用操作
3.1 添加元素
- push_back: 在末尾添加元素。
- push_front: 在前面添加元素。
myList.push_back(20);
myList.push_front(5);
3.2 删除元素
- pop_back: 删除末尾元素。
- pop_front: 删除前面元素。
- remove: 删除指定值的所有元素。
myList.pop_back(); // 删除末尾元素
myList.pop_front(); // 删除前面元素
myList.remove(10); // 删除所有值为 10 的元素
3.3 遍历元素
可以使用迭代器遍历 list
的元素:
for (auto it = myList.begin(); it != myList.end(); ++it) {
std::cout << *it << " ";
}
也可以使用范围基于的 for 循环:
for (const auto& value : myList) {
std::cout << value << " ";
}
4. 其他常用方法
- size: 获取元素数量。
- empty: 检查
list
是否为空。 - clear: 清空
list
中所有元素。
std::cout << "Size: " << myList.size() << std::endl;
std::cout << "Is empty: " << myList.empty() << std::endl;
myList.clear(); // 清空链表
5. 特点
1. 插入和删除的效率
- 常数时间插入和删除:
list
容器的插入和删除操作在常数时间内完成,前提是你已经有了指向插入或删除位置的迭代器。这使得list
非常适合需要频繁修改数据的场景。
2. 双向链表结构
- 结构描述:
list
的底层实现为双向链表,每个节点包含三个部分:数据、指向前一个节点的指针和指向下一个节点的指针。这种结构支持前向和后向迭代,方便遍历。
3. 与 forward_list
的比较
forward_list
:这是标准库中的单向链表,只能向前迭代。由于结构更简单,forward_list
的内存开销通常更小,且在某些情况下性能更好,但不支持反向遍历。
4. 与其他序列容器的比较
- 性能优势:与数组、
vector
和deque
等其他序列容器相比,list
在任意位置的插入和删除操作效率更高,因为其他容器在这些操作上可能需要移动大量元素。
5. 随机访问的缺陷
- 缺乏随机访问:
list
和forward_list
不支持随机访问。这意味着要访问第 n 个元素,必须从头或尾开始逐个迭代,时间复杂度为 O(n)。这一点在处理大量元素时可能会影响性能。 - 额外的内存开销:由于每个节点需要存储指向前后节点的指针,
list
会比存储简单数据类型的数组或vector
占用更多内存。这在存储大量小型对象时尤为明显。
在 C++ 中,std::list
提供了多种构造函数以便于创建和初始化链表。
1. list(size_type n, const value_type& val = value_type())
- 功能:构造一个包含
n
个元素的list
,每个元素的值都初始化为val
。 - 参数:
n
:要创建的元素数量。val
:可选参数,指定每个元素的初始值,默认值为value_type()
(即类型的默认构造)。
- 示例:
#include <iostream>
#include <list>
int main() {
std::list<int> myList(5, 10); // 创建一个包含 5 个值为 10 的元素的 list
for (const auto& value : myList) {
std::cout << value << " "; // 输出: 10 10 10 10 10
}
return 0;
}
2. list()
- 功能:构造一个空的
list
。 - 示例:
#include <iostream>
#include <list>
int main() {
std::list<int> myList; // 创建一个空的 list
std::cout << "Size: " << myList.size(); // 输出: Size: 0
return 0;
}
3. list(const list& x)
- 功能:拷贝构造函数,用于创建一个
list
的新实例,该实例是另一个list
的拷贝。 - 参数:
x
:要拷贝的list
对象。
- 示例:
#include <iostream>
#include <list>
int main() {
std::list<int> originalList = {1, 2, 3, 4, 5};
std::list<int> copiedList(originalList); // 通过拷贝构造函数创建一个新 list
for (const auto& value : copiedList) {
std::cout << value << " "; // 输出: 1 2 3 4 5
}
return 0;
}
4. list(InputIterator first, InputIterator last)
- 功能:使用
[first, last)
区间中的元素构造list
。这允许使用任何支持迭代器的容器(如vector
,array
等)来初始化list
。 - 参数:
first
:指向要复制的范围的起始位置的迭代器。last
:指向要复制的范围的结束位置的迭代器。
- 示例:
#include <iostream>
#include <list>
#include <vector>
int main() {
std::vector<int> vec = {10, 20, 30, 40, 50};
std::list<int> myList(vec.begin(), vec.end()); // 使用 vector 的元素初始化 list
for (const auto& value : myList) {
std::cout << value << " "; // 输出: 10 20 30 40 50
}
return 0;
}
#include <iostream>
#include <list>
#include <algorithm> // 添加以使用 std::find
using namespace std;
// 打印链表的函数
void print_list(const list<int>& l) {
list<int>::const_iterator it = l.begin(); // 获取链表的开始迭代器
while (it != l.end()) { // 遍历链表
cout << *it << " "; // 打印当前元素
++it; // 移动到下一个元素
}
cout << endl; // 打印换行
}
void test_list() {
list<int> l; // 创建一个空的整数链表
// 向链表尾部添加元素
l.push_back(1);
l.push_back(2);
l.push_back(3);
l.push_back(4);
l.push_back(5);
l.push_back(6);
l.push_back(7);
// 向链表头部添加元素
l.push_front(0);
l.push_front(-1);
l.push_front(-2);
// 使用迭代器遍历并打印链表的所有元素
list<int>::iterator it = l.begin();
while (it != l.end()) {
cout << *it << " "; // 打印当前元素
++it; // 移动到下一个元素
}
cout << endl; // 打印换行
// 删除链表尾部和头部的元素
l.pop_back(); // 删除最后一个元素
l.pop_front(); // 删除第一个元素
// 使用范围基于的 for 循环遍历链表并打印元素
for (auto e : l) {
cout << e << " "; // 打印当前元素
}
cout << endl; // 打印换行
// 使用反向迭代器遍历链表并打印元素
list<int>::reverse_iterator rit = l.rbegin();
while (rit != l.rend()) {
cout << *rit << " "; // 打印当前反向元素
++rit; // 移动到前一个元素
}
cout << endl << endl; // 打印换行
// 使用拷贝构造函数创建一个新的链表 l2
list<int> l2(l);
print_list(l2); // 打印 l2 的内容
// 查找元素 3 的位置
list<int>::iterator pos = find(l2.begin(), l2.end(), 3);
if (pos != l2.end()) { // 检查是否找到
l2.insert(pos, 30); // 在位置 pos 前插入 30
print_list(l2); // 打印插入后的链表
l2.erase(pos); // 删除元素 3
}
print_list(l2); // 打印最终链表
}
int main() {
test_list(); // 调用测试函数
return 0; // 程序结束
}
std::list
迭代器
在 C++ 中,std::list
提供了迭代器(iterator)来遍历和操作链表中的元素。以下是关于 list
迭代器使用的详细说明,包括基本用法和常见操作示例。
1. 迭代器类型
std::list
提供以下几种迭代器类型:
iterator
:可修改元素的迭代器。const_iterator
:只读迭代器,不能修改元素。reverse_iterator
:反向迭代器,用于反向遍历链表。
2. 使用 iterator
2.1 基本操作
- 创建迭代器:使用
begin()
和end()
方法获取迭代器。
#include <iostream>
#include <list>
using namespace std;
int main() {
list<int> myList = {1, 2, 3, 4, 5};
// 创建迭代器
list<int>::iterator it = myList.begin(); // 指向第一个元素
// 遍历并修改元素
while (it != myList.end()) {
*it *= 2; // 将当前元素的值乘以 2
++it; // 移动到下一个元素
}
// 打印修改后的列表
for (const auto& value : myList) {
cout << value << " "; // 输出: 2 4 6 8 10
}
cout << endl;
return 0;
}
3. 使用 const_iterator
const_iterator
用于只读访问元素,不能修改元素的值。
#include <iostream>
#include <list>
using namespace std;
int main() {
list<int> myList = {1, 2, 3, 4, 5};
// 创建只读迭代器
list<int>::const_iterator it = myList.cbegin(); // 指向第一个元素
// 遍历并打印元素
while (it != myList.cend()) {
cout << *it << " "; // 输出: 1 2 3 4 5
++it; // 移动到下一个元素
}
cout << endl;
return 0;
}
4. 使用 reverse_iterator
reverse_iterator
用于反向遍历链表。
#include <iostream>
#include <list>
using namespace std;
int main() {
list<int> myList = {1, 2, 3, 4, 5};
// 创建反向迭代器
list<int>::reverse_iterator rit = myList.rbegin(); // 指向最后一个元素
// 反向遍历并打印元素
while (rit != myList.rend()) {
cout << *rit << " "; // 输出: 5 4 3 2 1
++rit; // 移动到前一个元素
}
cout << endl;
return 0;
}
5. 常见操作
- 插入元素:
list<int>::iterator it = myList.begin();
++it; // 指向第二个元素
myList.insert(it, 10); // 在第二个位置插入 10
- 删除元素:
list<int>::iterator it = myList.begin();
myList.erase(it); // 删除指向的元素
- 查找元素:
使用 std::find
查找特定元素并返回其迭代器。
#include <algorithm>
list<int>::iterator pos = find(myList.begin(), myList.end(), 3);
if (pos != myList.end()) {
cout << "Found: " << *pos << endl; // 输出: Found: 3
}
以下是 std::list
中常用成员函数的函数声明和接口说明的总结表格:
函数名称 | 接口说明 |
---|---|
empty() | 检测 list 是否为空。如果为空返回 true ,否则返回 false 。 |
size() | 返回 list 中有效节点的个数。 |
front() | 返回 list 的第一个节点中值的引用。 |
back() | 返回 list 的最后一个节点中值的引用。 |
push_front(const value_type& val) | 在 list 首部插入值为 val 的元素。 |
pop_front() | 删除 list 中的第一个元素。 |
push_back(const value_type& val) | 在 list 尾部插入值为 val 的元素。 |
pop_back() | 删除 list 中的最后一个元素。 |
insert(iterator position, const value_type& val) | 在 list 的 position 位置插入值为 val 的元素。 |
erase(iterator position) | 删除 list 中 position 位置的元素。 |
swap(list& other) | 交换两个 list 中的元素。 |
clear() | 清空 list 中的有效元素。 |
详细说明
-
empty()
:bool empty() const;
-
size()
:size_type size() const;
-
front()
:value_type& front(); const value_type& front() const;
-
back()
:value_type& back(); const value_type& back() const;
-
push_front(const value_type& val)
:void push_front(const value_type& val);
-
pop_front()
:void pop_front();
-
push_back(const value_type& val)
:void push_back(const value_type& val);
-
pop_back()
:void pop_back();
-
insert(iterator position, const value_type& val)
:iterator insert(iterator position, const value_type& val);
-
erase(iterator position)
:iterator erase(iterator position);
-
swap(list& other)
:void swap(list& other) noexcept;
-
clear()
:void clear();
list的迭代器失效
迭代器失效即迭代器所指向的节点的无效,即该节 点被删除了。因为list的底层结构为带头结点的双向循环链表,因此在list中进行插入时是不会导致list的迭代 器失效的,只有在删除时才会失效,并且失效的只是指向被删除节点的迭代器,其他迭代器不会受到影响。
#include <iostream>
#include <list>
#include <algorithm> // 用于 std::find
using namespace std;
int main() {
list<int> myList = {1, 2, 3, 4, 5};
// 获取一个指向元素 3 的迭代器
auto it = find(myList.begin(), myList.end(), 3);
// 输出当前元素
if (it != myList.end()) {
cout << "Element found: " << *it << endl; // 输出: Element found: 3
}
// 删除元素 3
myList.erase(it); // 这会使得 it 失效
// 尝试使用失效的迭代器
// cout << *it << endl; // 注意:这会导致未定义行为
// 重新查找元素 4
it = find(myList.begin(), myList.end(), 4);
if (it != myList.end()) {
cout << "Element found: " << *it << endl; // 输出: Element found: 4
}
return 0;
}
处理迭代器失效
-
避免使用失效的迭代器:在执行删除操作后,确保不再使用指向被删除元素的迭代器。
-
更新迭代器:在插入或删除元素后,重新获取需要的迭代器。
以下是一个示例,展示如何使用 it = myList.erase(it)
来安全地删除元素并继续迭代:
#include <iostream>
#include <list>
using namespace std;
int main() {
list<int> myList = {1, 2, 3, 4, 5, 3, 6};
// 打印原始列表
cout << "Original list: ";
for (const auto& value : myList) {
cout << value << " ";
}
cout << endl;
// 查找并删除所有值为 3 的元素
auto it = myList.begin();
while (it != myList.end()) {
if (*it == 3) {
it = myList.erase(it); // 删除元素并更新迭代器
} else {
++it; // 仅在未删除时移动迭代器
}
}
// 打印更新后的列表
cout << "Updated list: ";
for (const auto& value : myList) {
cout << value << " ";
}
cout << endl;
return 0;
}
代码说明
- 原始列表:首先打印出原始列表的内容。
- 查找并删除:使用
while
循环遍历链表,查找值为3
的元素。- 当找到值为
3
的元素时,调用erase
删除该元素,并将返回的迭代器赋值给it
,以继续遍历剩余元素。 - 如果当前元素不等于
3
,则简单地移动到下一个元素。
- 当找到值为
- 更新列表:最后打印出更新后的列表。
总结
使用 it = myList.erase(it)
是一种安全的做法,可以有效地删除元素并继续遍历 std::list
,避免了迭代器失效的问题。
模拟实现list
#pragma once
#include <iostream>
namespace ptm
{
// 双向链表节点结构
template<class T>
struct __list_node
{
__list_node<T>* _next; // 指向下一个节点的指针
__list_node<T>* _prev; // 指向前一个节点的指针
T _data; // 节点存储的数据
// 构造函数,默认初始化数据
__list_node(const T& x = T())
: _data(x), _next(nullptr), _prev(nullptr)
{}
};
// 链表迭代器结构
template<class T, class Ref, class Ptr>
struct __list_iterator
{
typedef __list_node<T> Node; // 节点类型
typedef __list_iterator<T, Ref, Ptr> Self; // 自身类型
Node* _node; // 当前节点指针
// 构造函数
__list_iterator(Node* node)
: _node(node)
{}
// 解引用操作符
Ref operator*()
{
return _node->_data;
}
// 访问成员操作符
Ptr operator->()
{
return &_node->_data;
}
// 前缀自增操作符
Self& operator++()
{
_node = _node->_next;
return *this;
}
// 后缀自增操作符
Self operator++(int)
{
Self tmp(*this);
++(*this); // 调用前缀自增
return tmp;
}
// 前缀自减操作符
Self& operator--()
{
_node = _node->_prev;
return *this;
}
// 后缀自减操作符
Self operator--(int)
{
Self tmp(*this);
--(*this); // 调用前缀自减
return tmp;
}
// 不等于操作符
bool operator != (const Self& it)
{
return _node != it._node;
}
// 等于操作符
bool operator == (const Self& it)
{
return _node == it._node;
}
};
// 日期结构
struct Date
{
int _year = 0;
int _month = 1;
int _day = 1;
};
// 双向链表类
template<class T>
class list
{
typedef __list_node<T> Node; // 节点类型别名
public:
typedef __list_iterator<T, T&, T*> iterator; // 可修改的迭代器
typedef __list_iterator<T, const T&, const T*> const_iterator; // 常量迭代器
// 返回链表的起始迭代器
iterator begin()
{
return iterator(_head->_next);
}
// 返回链表的结束迭代器
iterator end()
{
return iterator(_head);
}
// 返回常量迭代器的起始位置
const_iterator begin() const
{
return const_iterator(_head->_next);
}
// 返回常量迭代器的结束位置
const_iterator end() const
{
return const_iterator(_head);
}
// 默认构造函数
list()
{
_head = new Node; // 创建虚拟头节点
_head->_next = _head; // 头节点指向自身
_head->_prev = _head; // 头节点指向自身
}
// 拷贝构造函数
list(const list<T>& lt)
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
// 通过遍历源链表,插入新元素
for (auto e : lt)
{
push_back(e);
}
}
// 赋值运算符重载(拷贝赋值)
list<T>& operator=(const list<T>& lt)
{
if (this != <) // 防止自赋值
{
clear(); // 清空当前链表
for (auto e : lt) // 复制源链表的元素
{
push_back(e);
}
}
return *this;
}
// 移动赋值运算符重载
list<T>& operator=(list<T> lt)
{
swap(_head, lt._head); // 交换当前链表与临时链表
return *this; // 返回当前链表
}
// 析构函数
~list()
{
clear(); // 清空链表
delete _head; // 删除头节点
_head = nullptr; // 安全处理
}
// 清空链表
void clear()
{
iterator it = begin();
while (it != end())
{
erase(it++); // 逐个删除节点
}
}
// 在尾部插入新元素
void push_back(const T& x)
{
insert(end(), x); // 在结束位置插入新节点
}
// 在头部插入新元素
void push_front(const T& x)
{
insert(begin(), x); // 在起始位置插入新节点
}
// 删除尾部元素
void pop_back()
{
erase(--end()); // 删除尾部节点
}
// 删除头部元素
void pop_front()
{
erase(begin()); // 删除头部节点
}
// 在指定位置插入新元素
void insert(iterator pos, const T& x)
{
Node* cur = pos._node; // 当前节点
Node* prev = cur->_prev; // 前驱节点
Node* newnode = new Node(x); // 创建新节点
// 更新指针
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
}
// 删除指定位置的节点
void erase(iterator pos)
{
Node* cur = pos._node; // 当前节点
Node* prev = cur->_prev; // 前驱节点
Node* next = cur->_next; // 后继节点
delete cur; // 删除当前节点
// 更新指针
prev->_next = next;
next->_prev = prev;
}
private:
Node* _head; // 虚拟头节点
};
}
// 外部函数声明
extern void test_list();
extern void print_list(const ptm::list<int>& lt);
#include "mlist.h"
using namespace ptm;
void test_list()
{
list<int> l;
l.push_back(1);
l.push_back(2);
l.push_back(3);
l.push_back(4);
l.push_back(5);
list<int>::iterator it = l.begin();
while (it != l.end())
{
std::cout << *it << " ";
++it;
}
std::cout << std::endl;
list<Date> lt;
lt.push_back(Date());
lt.push_back(Date());
list<Date>::iterator t = lt.begin();
while (t != lt.end())
{
std::cout << (*t)._year << "-" << t->_month << "-" << t->_day << std::endl;
++t;
}
}
void print_list(const list<int>& lt)
{
list<int>::const_iterator it = lt.begin();
while (it != lt.end())
{
std::cout << *it << " ";
++it;
}
std::cout << std::endl;
}
#include "mlist.h"
int main()
{
test_list();
ptm::list<int> l;
l.push_back(0);
l.push_back(5);
l.push_back(9);
print_list(l);
return 0;
}
list与vector的对比
在 C++ 中,list
和 vector
都是常用的容器类型,它们各自有不同的特性和适用场景。
以下是二者的详细对比:
1. 数据结构
-
vector
:- 采用动态数组实现,元素在内存中是连续存储的。
- 访问元素速度快,因为可以通过索引直接访问。
-
list
:- 采用双向链表实现,节点不是连续存储的。
- 每个节点包含指向前一个和后一个节点的指针。
2. 时间复杂度
-
插入和删除:
vector
:- 在尾部插入 (
push_back
) 通常是 O(1),但在中间或头部插入和删除是 O(n),因为需要移动元素。
- 在尾部插入 (
list
:- 在任意位置插入和删除是 O(1),前提是你已经获得了该位置的迭代器。
-
访问元素:
vector
:O(1)(通过索引)list
:O(n)(需要遍历链表)
3. 内存管理
-
vector
:- 内存使用上是连续的,这可能导致内存碎片。
- 当容量不足时,
vector
会重新分配内存并复制现有元素。
-
list
:- 内存使用上是离散的,每个节点单独分配。
- 不会因为容量问题而重新分配,但会有额外的指针开销。
4. 适用场景
-
使用
vector
的场景:- 需要频繁随机访问元素。
- 插入和删除操作主要发生在尾部。
- 对内存使用有较高的要求(需要更紧凑的存储)。
-
使用
list
的场景:- 需要频繁在中间插入和删除元素。
- 不需要随机访问元素。
- 元素数量经常变化,且需要高效的插入和删除。
5. 迭代器
vector
:- 迭代器是随机访问迭代器,支持加法和减法运算。
list
:- 迭代器是双向迭代器,只支持前进和后退。
6. 性能考虑
-
vector
:- 在大多数情况下,
vector
的性能优于list
,尤其是在需要频繁访问元素时。 - 由于
vector
的内存是连续的,缓存友好性更好。
- 在大多数情况下,
-
list
:- 在需要频繁插入和删除的情况下,
list
更具优势,尤其是在中间位置。
- 在需要频繁插入和删除的情况下,
总结
特性 | vector | list |
---|---|---|
数据结构 | 动态数组 | 双向链表 |
随机访问 | O(1) | O(n) |
插入/删除 | 尾部 O(1),头部/中间 O(n) | O(1) |
内存管理 | 连续内存,可能导致碎片 | 离散内存,每个节点单独分配 |
适用场景 | 频繁访问,少量插入/删除 | 频繁插入/删除 |
迭代器类型 | 随机访问迭代器 | 双向迭代器 |
stack和queue
在 C++ 中,stack
和 queue
是 STL(标准模板库)中重要的容器适配器,提供了对数据的特定访问模式。
1. 栈(Stack)
定义
栈是一种后进先出(LIFO,Last In First Out)数据结构。最后添加的元素会最先被移除。
主要操作
push
:将元素添加到栈顶。pop
:移除栈顶元素。top
(或peek
):访问栈顶元素,但不移除它。empty
:检查栈是否为空。size
:返回栈中元素的数量。
示例代码
#include <iostream>
#include <stack>
int main() {
std::stack<int> s;
// 入栈
s.push(1);
s.push(2);
s.push(3);
// 查看栈顶元素
std::cout << "Top element: " << s.top() << std::endl; // 输出: 3
// 出栈并显示栈顶元素
s.pop();
std::cout << "Top element after pop: " << s.top() << std::endl; // 输出: 2
// 检查栈是否为空
std::cout << "Stack size: " << s.size() << std::endl; // 输出: 2
return 0;
}
使用场景
- 函数调用管理:维护函数调用的上下文。
- 表达式求值:如逆波兰表示法的计算。
- 深度优先搜索:用于图形和树的遍历。
2. 队列(Queue)
定义
队列是一种先进先出(FIFO,First In First Out)数据结构。最先添加的元素会最先被移除。
主要操作
enqueue
(或push
):将元素添加到队尾。dequeue
(或pop
):移除队头元素。front
(或peek
):访问队头元素,但不移除它。empty
:检查队列是否为空。size
:返回队列中元素的数量。
示例代码
#include <iostream>
#include <queue>
int main() {
std::queue<int> q;
// 入队
q.push(1);
q.push(2);
q.push(3);
// 查看队头元素
std::cout << "Front element: " << q.front() << std::endl; // 输出: 1
// 出队并显示队头元素
q.pop();
std::cout << "Front element after pop: " << q.front() << std::endl; // 输出: 2
// 检查队列是否为空
std::cout << "Queue size: " << q.size() << std::endl; // 输出: 2
return 0;
}
使用场景
- 任务调度:维护任务执行的顺序。
- 排队系统:如打印队列或银行排队。
- 广度优先搜索:用于图形和树的遍历。
3. 比较与总结
栈与队列的比较
特性 | 栈 | 队列 |
---|---|---|
数据访问方式 | 后进先出 (LIFO) | 先进先出 (FIFO) |
主要操作 | push , pop , top | enqueue , dequeue , front |
适用场景 | 函数调用、回溯、DFS | 排队、任务调度、BFS |
选择合适的数据结构
- 栈:适合处理需要回溯或撤销操作的场景。
- 队列:适合处理顺序执行的任务,特别是在多任务系统中。
4. 扩展:双端队列(Deque)
C++ 还提供了一个双端队列 std::deque
,允许在两端进行插入和删除操作。
示例代码
#include <iostream>
#include <deque>
int main() {
std::deque<int> dq;
// 从后端入队
dq.push_back(1);
dq.push_back(2);
// 从前端入队
dq.push_front(0);
// 查看队头元素
std::cout << "Front element: " << dq.front() << std::endl; // 输出: 0
// 出队
dq.pop_front();
std::cout << "Front element after pop: " << dq.front() << std::endl; // 输出: 1
return 0;
}
逆波兰表达式
逆波兰表达式(Reverse Polish Notation,RPN),也称为后缶表示法,是一种无须括号的数学表达式表示法。在这种表示法中,运算符放在操作数的后面。逆波兰表达式的主要优点是避免了传统中缀表达式中括号的使用,从而简化了计算过程。
1. 基本概念
操作数:即参与运算的数字(例如,2、3)。
运算符:表示数学运算的符号(例如,+、-、*、/)。
2. 示例
中缀表达式(传统表示法):3 + 4
逆波兰表达式:3 4 +
更复杂的表达式:
中缀:(3 + 4) * 5
逆波兰:3 4 + 5 *
3. 计算过程
逆波兰表达式的计算通常使用栈来实现。计算的基本步骤如下:
从左到右扫描表达式。
遇到操作数时,将其压入栈中。
遇到运算符时,从栈中弹出相应数量的操作数,进行计算,并将结果压入栈中。
表达式结束后,栈顶的值就是结果。
4. 计算示例
考虑逆波兰表达式 3 4 + 2 * 的计算过程:
遇到 3,压入栈:[3]
遇到 4,压入栈:[3, 4]
遇到 +,弹出 4 和 3,计算 3 + 4 = 7,将结果压入栈:[7]
遇到 2,压入栈:[7, 2]
遇到 *,弹出 2 和 7,计算 7 * 2 = 14,将结果压入栈:[14]
表达式结束,栈顶的值 14 是最终结果。
5. 优点
无须括号:由于运算顺序由操作符的顺序决定,无需使用括号来明确运算顺序。
计算效率高:可以通过栈简单高效地实现计算。
6. 应用
逆波兰表达式在计算器和编程语言的解析器中广泛应用,特别是在需要高效计算的场合。
7. 总结
逆波兰表达式是一种简洁有效的表达数学运算的方法,利用栈结构来实现其计算过程,避免了括号的复杂性,使得表达式的解析与计算更加高效。
stack的模拟实现
创建一个自定义的栈类,支持基本的栈操作,如入栈、出栈、查看栈顶元素、检查栈是否为空,以及获取栈的大小。
栈的基本操作
- 入栈(push):将元素添加到栈顶。
- 出栈(pop):移除并返回栈顶元素。
- 查看栈顶元素(top):返回栈顶元素但不移除。
- 检查栈是否为空(isEmpty):判断栈中是否有元素。
- 获取栈的大小(size):返回栈中元素的数量。
示例代码
#include <iostream>
#include <vector>
#include <stdexcept>
class Stack {
private:
std::vector<int> elements; // 用于存储栈中的元素
public:
// 入栈
void push(int value) {
elements.push_back(value);
}
// 出栈
void pop() {
if (isEmpty()) {
throw std::runtime_error("Stack is empty");
}
elements.pop_back();
}
// 查看栈顶元素
int top() {
if (isEmpty()) {
throw std::runtime_error("Stack is empty");
}
return elements.back();
}
// 检查栈是否为空
bool isEmpty() {
return elements.empty();
}
// 获取栈的大小
int size() {
return elements.size();
}
};
int main() {
Stack stack;
// 测试栈操作
stack.push(10);
stack.push(20);
stack.push(30);
std::cout << "Top element: " << stack.top() << std::endl; // 输出: 30
std::cout << "Stack size: " << stack.size() << std::endl; // 输出: 3
stack.pop();
std::cout << "Top element after pop: " << stack.top() << std::endl; // 输出: 20
std::cout << "Stack size: " << stack.size() << std::endl; // 输出: 2
stack.pop();
stack.pop();
// 检查栈是否为空
if (stack.isEmpty()) {
std::cout << "Stack is empty now." << std::endl;
}
return 0;
}
代码解释
- 内部存储:使用
std::vector<int>
来动态存储栈中的元素。 - 入栈:通过
push_back
方法将元素添加到vector
的末尾。 - 出栈:通过
pop_back
方法移除vector
的最后一个元素,先检查栈是否为空。 - 查看栈顶:使用
back
方法获取最后一个元素。 - 空栈检查:使用
empty
方法判断vector
是否为空。 - 获取大小:直接返回
vector
的大小。
queue的模拟实现
创建一个自定义的队列类,支持基本的队列操作,如入队、出队、查看队头元素、检查队列是否为空,以及获取队列的大小。
队列的基本操作
- 入队(enqueue):将元素添加到队尾。
- 出队(dequeue):移除并返回队头元素。
- 查看队头元素(front):返回队头元素但不移除。
- 检查队列是否为空(isEmpty):判断队列中是否有元素。
- 获取队列的大小(size):返回队列中元素的数量。
示例代码
#include <iostream>
#include <vector>
#include <stdexcept>
class Queue {
private:
std::vector<int> elements; // 用于存储队列中的元素
public:
// 入队
void enqueue(int value) {
elements.push_back(value); // 使用 push_back 将元素添加到队尾
}
// 出队
void dequeue() {
if (isEmpty()) {
throw std::runtime_error("Queue is empty");
}
elements.erase(elements.begin()); // 移除队头元素
}
// 查看队头元素
int front() {
if (isEmpty()) {
throw std::runtime_error("Queue is empty");
}
return elements.front(); // 返回队头元素
}
// 检查队列是否为空
bool isEmpty() {
return elements.empty(); // 判断是否为空
}
// 获取队列的大小
int size() {
return elements.size(); // 返回元素数量
}
};
int main() {
Queue queue;
// 测试队列操作
queue.enqueue(10);
queue.enqueue(20);
queue.enqueue(30);
std::cout << "Front element: " << queue.front() << std::endl; // 输出: 10
std::cout << "Queue size: " << queue.size() << std::endl; // 输出: 3
queue.dequeue();
std::cout << "Front element after dequeue: " << queue.front() << std::endl; // 输出: 20
std::cout << "Queue size: " << queue.size() << std::endl; // 输出: 2
queue.dequeue();
queue.dequeue();
// 检查队列是否为空
if (queue.isEmpty()) {
std::cout << "Queue is empty now." << std::endl;
}
return 0;
}
代码解释
- 内部存储:使用
std::vector<int>
来动态存储队列中的元素。 - 入队:通过
push_back
方法将元素添加到vector
的末尾,表示队尾。 - 出队:通过
erase
方法移除vector
的第一个元素,表示队头。 - 查看队头:使用
front
方法获取vector
的第一个元素。 - 空队列检查:使用
empty
方法判断vector
是否为空。 - 获取大小:直接返回
vector
的大小。
性能注意
虽然这个实现简单易懂,但在出队操作中使用 erase
会导致 O(n) 的时间复杂度,因为它需要移动所有后续元素。对于更高效的队列实现,可以考虑使用双端队列 std::deque
或循环数组。
priority_queue的介绍和使用
std::priority_queue
是 C++ STL 中的一种容器适配器,用于实现优先队列。它允许我们以优先级的顺序访问元素,通常用于需要快速获取最大或最小元素的场景。
1. 基本概念
- 优先队列:是一种特殊的队列,其中每个元素都有一个优先级。高优先级的元素会在队列中被优先处理。
- 默认行为:
std::priority_queue
默认是一个最大堆(max-heap),这意味着具有最高优先级的元素总是位于队头。
2. 主要操作
- 入队(
push
):将元素添加到优先队列中。 - 出队(
pop
):移除优先队列中的最高优先级元素。 - 查看队头元素(
top
):访问优先队列中的最高优先级元素,但不移除它。 - 检查队列是否为空(
empty
):判断优先队列是否包含元素。 - 获取队列的大小(
size
):返回优先队列中元素的数量。
3. 使用方法
std::priority_queue
可以使用任意类型的元素和自定义比较器。以下是一些常见的用法:
示例代码
#include <iostream>
#include <queue>
#include <vector>
#include <functional> // 用于 std::greater
int main() {
// 创建一个最大堆的优先队列
std::priority_queue<int> maxHeap;
// 入队
maxHeap.push(10);
maxHeap.push(30);
maxHeap.push(20);
// 查看并移除最大元素
std::cout << "Max element: " << maxHeap.top() << std::endl; // 输出: 30
maxHeap.pop();
// 查看下一个最大元素
std::cout << "Next max element: " << maxHeap.top() << std::endl; // 输出: 20
// 创建一个最小堆的优先队列
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
// 入队
minHeap.push(10);
minHeap.push(30);
minHeap.push(20);
// 查看并移除最小元素
std::cout << "Min element: " << minHeap.top() << std::endl; // 输出: 10
minHeap.pop();
// 查看下一个最小元素
std::cout << "Next min element: " << minHeap.top() << std::endl; // 输出: 20
return 0;
}
4. 自定义比较器
你可以使用自定义比较器来定义优先级的排序方式。例如,若要实现一个根据结构体的某个成员进行排序的优先队列,可以使用如下方法:
#include <iostream>
#include <queue>
#include <vector>
struct Task {
int id;
int priority;
// 自定义比较器
bool operator<(const Task& other) const {
return priority < other.priority; // 高优先级在前
}
};
int main() {
std::priority_queue<Task> taskQueue;
taskQueue.push({1, 3});
taskQueue.push({2, 1});
taskQueue.push({3, 2});
while (!taskQueue.empty()) {
Task t = taskQueue.top();
std::cout << "Task ID: " << t.id << ", Priority: " << t.priority << std::endl;
taskQueue.pop();
}
return 0;
}
5. 优缺点
优点:
- 高效性:优先队列操作的时间复杂度为 O(log n),适用于需要频繁插入和删除最高优先级元素的场景。
- 灵活性:支持自定义数据类型和比较器,适应不同的需求。
缺点:
- 内存开销:由于维护堆结构,内存管理相对复杂。
- 不支持随机访问:只能访问最高优先级的元素,无法直接访问其他元素。
6. 应用场景
- 任务调度:在操作系统中,调度高优先级任务。
- 图算法:如 Dijkstra 算法和 Prim 算法中使用的优先队列。
- 事件驱动模拟:处理时间优先的事件。
优先队列(priority_queue
)的模拟实现
下面是一个简单的优先队列(priority_queue
)的模拟实现, 实现一个最大堆(max-heap)作为底层结构,以支持优先队列的基本操作。
1. 基本概念
- 最大堆:每个节点的值都大于或等于其子节点的值。堆的根节点是最大元素。
- 优先队列操作:
- 入队(
push
):将元素添加到优先队列。 - 出队(
pop
):移除并返回最大元素。 - 查看最大元素(
top
):返回最大元素但不移除。
- 入队(
2. 实现代码
下面是一个简单的优先队列实现:
#include <iostream>
#include <vector>
#include <stdexcept>
class PriorityQueue {
private:
std::vector<int> heap; // 存储堆的数组
// 上浮操作
void siftUp(int index) {
while (index > 0) {
int parent = (index - 1) / 2;
if (heap[index] > heap[parent]) {
std::swap(heap[index], heap[parent]);
index = parent;
} else {
break;
}
}
}
// 下沉操作
void siftDown(int index) {
int size = heap.size();
while (index < size) {
int leftChild = 2 * index + 1;
int rightChild = 2 * index + 2;
int largest = index;
if (leftChild < size && heap[leftChild] > heap[largest]) {
largest = leftChild;
}
if (rightChild < size && rightChild < size && heap[rightChild] > heap[largest]) {
largest = rightChild;
}
if (largest != index) {
std::swap(heap[index], heap[largest]);
index = largest;
} else {
break;
}
}
}
public:
// 入队
void push(int value) {
heap.push_back(value); // 添加元素到堆的末尾
siftUp(heap.size() - 1); // 上浮调整
}
// 出队
void pop() {
if (heap.empty()) {
throw std::runtime_error("Priority Queue is empty");
}
// 将最后一个元素移到根部并下沉
heap[0] = heap.back();
heap.pop_back();
siftDown(0); // 下沉调整
}
// 查看最大元素
int top() {
if (heap.empty()) {
throw std::runtime_error("Priority Queue is empty");
}
return heap[0]; // 返回根部元素
}
// 检查优先队列是否为空
bool empty() {
return heap.empty();
}
// 获取优先队列的大小
int size() {
return heap.size();
}
};
int main() {
PriorityQueue pq;
// 测试优先队列操作
pq.push(30);
pq.push(10);
pq.push(20);
pq.push(40);
std::cout << "Max element: " << pq.top() << std::endl; // 输出: 40
pq.pop();
std::cout << "Next max element: " << pq.top() << std::endl; // 输出: 30
pq.pop();
std::cout << "Size after popping: " << pq.size() << std::endl; // 输出: 2
return 0;
}
3. 代码解释
-
内部存储:使用
std::vector<int>
来存储堆的元素。 -
入队(
push
):- 将新元素添加到
heap
的末尾,并调用siftUp
方法调整堆结构。
- 将新元素添加到
-
出队(
pop
):- 将根部元素替换为堆的最后一个元素,然后移除最后一个元素,并调用
siftDown
方法调整堆结构。
- 将根部元素替换为堆的最后一个元素,然后移除最后一个元素,并调用
-
查看最大元素(
top
):直接返回heap[0]
。 -
上浮和下沉:
siftUp
:用于维护堆的性质,将新插入的元素上浮到正确位置。siftDown
:用于维护堆的性质,将根元素下沉到正确位置。
4. 优缺点
优点:
- 高效:入队和出队操作的时间复杂度为 O(log n)。
- 简洁性:通过维护堆结构,提供了优先队列的基本功能。
缺点:
- 内存开销:可能不如 STL 的
std::priority_queue
效率高,特别是在内存管理方面。 - 功能有限:没有 STL 实现的丰富功能,比如自定义比较器等。
容器适配器(Container Adapters)
容器适配器(Container Adapters)是 C++ STL 中的一种设计模式,用于提供对基础容器的特定接口。这些适配器在底层使用其他容器(如 vector
、deque
或 list
)来实现特定的数据结构行为,而用户只需要使用适配器提供的接口。
1. 主要类型
C++ STL 中的主要容器适配器包括:
-
std::stack
:- 实现了后进先出(LIFO)的数据结构。
- 通常基于
deque
或vector
实现。 - 提供的主要操作:
push()
: 将元素添加到栈顶。pop()
: 移除栈顶元素。top()
: 返回栈顶元素。
-
std::queue
:- 实现了先进先出(FIFO)的数据结构。
- 通常基于
deque
实现。 - 提供的主要操作:
push()
: 将元素添加到队尾。pop()
: 移除队头元素。front()
: 返回队头元素。
-
std::priority_queue
:- 实现了优先队列的数据结构,支持按优先级访问元素。
- 通常基于最大堆(max-heap)实现。
- 提供的主要操作:
push()
: 将元素添加到优先队列中。pop()
: 移除并返回最高优先级的元素。top()
: 返回最高优先级的元素。
2. 容器适配器的特点
- 封装性:容器适配器封装了底层容器的实现细节,用户只需关注适配器的接口。
- 灵活性:可以选择不同的底层容器来实现适配器,例如
std::stack
可以基于deque
或vector
。 - 简化使用:提供简洁的接口,适用于特定的数据结构需求。
3. 使用示例
以下是使用容器适配器的简单示例代码:
使用 std::stack
#include <iostream>
#include <stack>
int main() {
std::stack<int> s;
// 入栈
s.push(1);
s.push(2);
s.push(3);
// 查看栈顶元素
std::cout << "Top element: " << s.top() << std::endl; // 输出: 3
// 出栈
s.pop();
std::cout << "Top element after pop: " << s.top() << std::endl; // 输出: 2
return 0;
}
使用 std::queue
#include <iostream>
#include <queue>
int main() {
std::queue<int> q;
// 入队
q.push(1);
q.push(2);
q.push(3);
// 查看队头元素
std::cout << "Front element: " << q.front() << std::endl; // 输出: 1
// 出队
q.pop();
std::cout << "Front element after pop: " << q.front() << std::endl; // 输出: 2
return 0;
}
使用 std::priority_queue
#include <iostream>
#include <queue>
int main() {
std::priority_queue<int> pq;
// 入队
pq.push(10);
pq.push(30);
pq.push(20);
// 查看最大元素
std::cout << "Max element: " << pq.top() << std::endl; // 输出: 30
pq.pop();
std::cout << "Next max element: " << pq.top() << std::endl; // 输出: 20
return 0;
}
4. 总结
容器适配器在 C++ STL 中提供了灵活的接口,以满足不同的数据结构需求。通过封装底层容器的实现,适配器使得用户能够更加专注于操作的逻辑,而不必关心具体的实现细节。这种设计模式有助于提高代码的可维护性和可读性。
STL标准库中stack和queue的底层结构
虽然stack和queue中也可以存放元素,但在STL中并没有将其划分在容器的行列,而是将其称为容器适配器,这是因为stack和队列只是对其他容器的接口进行了包装,STL中stack和queue默认使用deque。
deque(双端队列)
deque(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和 删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高。
std::deque
(双端队列)是 C++ 标准模板库(STL)中提供的一种序列容器,它允许在两端高效地插入和删除元素。与 std::vector
不同,std::deque
适合在头部和尾部进行频繁操作。
1. 基本特性
- 双端操作:可以在队列的两端(头部和尾部)进行高效的插入和删除操作。
- 动态大小:可以根据需要动态调整大小,支持在运行时添加或移除元素。
- 随机访问:支持通过索引访问元素,时间复杂度为 O(1)。
2. 主要成员函数
-
构造和赋值:
deque<T>
:默认构造函数。deque(size_t count, const T& value)
:构造一个包含指定数量元素的队列。deque(const deque& other)
:拷贝构造函数。
-
元素访问:
front()
:返回队头元素。back()
:返回队尾元素。operator[]
:通过索引访问元素。
-
修改操作:
push_front(const T& value)
:在队头插入元素。push_back(const T& value)
:在队尾插入元素。pop_front()
:移除队头元素。pop_back()
:移除队尾元素。
-
大小和容量:
size()
:返回队列中的元素数量。empty()
:检查队列是否为空。clear()
:移除所有元素。
3. 使用示例
以下是使用 std::deque
的简单示例代码:
#include <iostream>
#include <deque>
int main() {
std::deque<int> dq;
// 插入元素
dq.push_back(10); // 队尾插入
dq.push_back(20);
dq.push_front(5); // 队头插入
// 查看队头和队尾元素
std::cout << "Front element: " << dq.front() << std::endl; // 输出: 5
std::cout << "Back element: " << dq.back() << std::endl; // 输出: 20
// 删除元素
dq.pop_front(); // 移除队头
dq.pop_back(); // 移除队尾
std::cout << "Size after popping: " << dq.size() << std::endl; // 输出: 1
std::cout << "Front element after pop: " << dq.front() << std::endl; // 输出: 10
// 检查是否为空
if (dq.empty()) {
std::cout << "Deque is empty." << std::endl;
} else {
std::cout << "Deque is not empty." << std::endl;
}
return 0;
}
4. 优缺点
优点:
- 高效的双端操作:在队头和队尾的插入和删除操作时间复杂度为 O(1)。
- 灵活性:可以动态调整大小,适应不同的需求。
缺点:
- 内存开销:由于使用了多个块的动态数组实现,可能导致内存碎片。
- 随机访问性能:虽然支持随机访问,但由于底层实现的特性,性能可能不如
std::vector
。
5. 应用场景
- 任务调度:可以用作调度队列,处理任务或事件。
- 缓存实现:适合实现双端缓存(如 LRU 缓存)。
- 数据流处理:实时处理数据流,支持在两端快速操作。
deque的缺陷
与vector比较,deque的优势是:头部插入和删除时,不需要搬移元素,效率特别高,而且在扩容时,也不需要搬移大量的元素,因此其效率是比vector高的。
与list比较,其底层是连续空间,空间利用率比较高,不需要存储额外字段。
但是,deque有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作为stack和queue的底层数据结构。
为什么选择deque作为stack和queue的底层默认容器
stack是一种后进先出的特殊线性数据结构,因此只要具有push_back()和pop_back()操作的线性结构,都可以作为stack的底层容器,比如vector和list都可以;queue是先进先出的特殊线性数据结构,只要具有push_back和pop_front操作的线性结构,都可以作为queue的底层容器,比如list。但是STL中对stack和queue默认选择deque作为其底层容器,主要是因为:
1. stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作。
2. 在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据);queue中的元素增长时,deque不仅效率高,而且内存使用率高。结合了deque的优点,而完美的避开了其缺陷。
STL标准库中对于stack和queue的模拟实现
stack
的模拟实现
#include <deque>
namespace ptm {
template<class T, class Con = std::deque<T>>
class stack {
public:
stack() {}
void push(const T& x) {
_c.push_back(x); // 在队尾插入元素
}
void pop() {
_c.pop_back(); // 移除队尾元素
}
T& top() {
return _c.back(); // 返回队尾元素
}
const T& top() const {
return _c.back(); // 返回队尾元素(常量版本)
}
size_t size() const {
return _c.size(); // 返回栈的大小
}
bool empty() const {
return _c.empty(); // 检查栈是否为空
}
private:
Con _c; // 使用默认的容器
};
} // namespace bite
queue
的模拟实现
#include <deque>
namespace ptm {
template<class T, class Con = std::deque<T>>
class queue {
public:
queue() {}
void push(const T& x) {
_c.push_back(x); // 在队尾插入元素
}
void pop() {
_c.pop_front(); // 移除队头元素
}
T& back() {
return _c.back(); // 返回队尾元素
}
const T& back() const {
return _c.back(); // 返回队尾元素(常量版本)
}
T& front() {
return _c.front(); // 返回队头元素
}
const T& front() const {
return _c.front(); // 返回队头元素(常量版本)
}
size_t size() const {
return _c.size(); // 返回队列的大小
}
bool empty() const {
return _c.empty(); // 检查队列是否为空
}
private:
Con _c; // 使用默认的容器
};
} // namespace bite
实现说明
- 容器类型:这两个类使用模板参数
Con
,默认为std::deque<T>
,可以根据需要替换为其他容器,如std::vector<T>
或std::list<T>
。 - 基本操作:
stack
提供了push
、pop
、top
、size
和empty
方法。queue
提供了push
、pop
、front
、back
、size
和empty
方法。
- 接口设计:这些方法提供了与 STL 中的
std::stack
和std::queue
类似的接口,简化了用户的使用。
仿函数(Functor)
仿函数(Functor)是 C++ 中的一种对象,它可以像函数一样被调用。具体来说,仿函数是重载了 operator()
的类或结构体实例。这使得对象不仅能够存储状态,还可以执行操作。
1. 基本概念
- 定义:仿函数是一个类或结构体,该类或结构体重载了函数调用运算符
operator()
。 - 用途:仿函数常用于算法和 STL 容器中,作为可传递的行为(如排序、比较等)。
2. 仿函数的定义
以下是一个简单的仿函数示例:
#include <iostream>
class Adder {
public:
Adder(int x) : x_(x) {}
// 重载 operator()
int operator()(int y) const {
return x_ + y;
}
private:
int x_; // 存储状态
};
int main() {
Adder addFive(5); // 创建一个仿函数对象,固定加数为 5
std::cout << "5 + 10 = " << addFive(10) << std::endl; // 调用仿函数,输出: 15
return 0;
}
3. 仿函数的优点
- 状态管理:仿函数可以保存状态(如在构造函数中初始化的成员变量),这在某些情况下比简单的函数指针更灵活。
- 类型安全:由于仿函数是类型安全的对象,可以利用 C++ 的类型系统进行更安全的编程。
- 与 STL 兼容:许多 STL 算法和容器(如
std::sort
、std::for_each
)可以接受仿函数作为参数,从而提供自定义行为。
4. 使用仿函数的示例
4.1. 排序示例
#include <iostream>
#include <vector>
#include <algorithm>
class Compare {
public:
bool operator()(int a, int b) const {
return a > b; // 降序排序
}
};
int main() {
std::vector<int> nums = {5, 2, 9, 1, 5, 6};
std::sort(nums.begin(), nums.end(), Compare()); // 使用仿函数进行排序
std::cout << "Sorted numbers: ";
for (int num : nums) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
模板
非类型模板参数
模板参数分类类型形参与非类型形参。
类型形参即:出现在模板参数列表中,跟在class或者typename之类的参数类型名称。
非类型形参,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。
注意:
1. 浮点数、类对象以及字符串是不允许作为非类型模板参数的。
2. 非类型的模板参数必须在编译期就能确认结果。
非类型模板参数是 C++ 模板编程中的一个特性,允许在模板中使用非类型的参数(如整数、指针、引用等),而不仅仅是类型。这种特性使得模板更加灵活和强大。
1. 基本概念
非类型模板参数可以用来指定一些常量值,这些值在编译时是已知的。它们可以是以下类型:
- 整数类型(如
int
,char
) - 指针类型
- 引用类型
- 布尔类型
2. 语法
非类型模板参数的定义和使用方式如下:
template <typename T, T value>
class MyClass {
public:
void printValue() {
std::cout << value << std::endl;
}
};
3. 示例
下面是一个简单的例子,演示如何使用非类型模板参数:
#include <iostream>
template <typename T, T value>
class MyArray {
private:
T arr[value]; // 使用非类型模板参数作为数组大小
public:
void printSize() {
std::cout << "数组大小: " << value << std::endl;
}
};
int main() {
MyArray<int, 5> myArray; // 创建一个大小为 5 的整数数组
myArray.printSize(); // 输出: 数组大小: 5
return 0;
}
#include <iostream>
using namespace std;
template <class T,int N>
class Array
{
public:
private:
T _a[N];
};
int main()
{
Array<int,100> a1;//100
Array<int,1000> a2;//1000
}
4. 特性
- 在编译时确定:非类型模板参数的值在编译时确定,允许编译器进行优化。
- 类型推导:C++ 可以根据传递的参数类型自动推导模板参数类型。
- 多种类型:可以使用不同类型的非类型参数,例如整型和指针。
5. 使用限制
- 非类型模板参数必须是常量表达式,因此它不能是运行时计算的值。
- 非类型模板参数的类型必须是可用于作为模板参数的类型。
6. 复杂示例
还可以结合多个非类型模板参数,甚至与类型模板参数组合使用:
#include <iostream>
template <typename T, T value1, T value2>
class MyMath {
public:
static T sum() {
return value1 + value2;
}
};
int main() {
std::cout << "Sum: " << MyMath<int, 3, 4>::sum() << std::endl; // 输出: Sum: 7
return 0;
}
模板的特化
在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。
函数模板特化
函数模板的特化步骤:
1. 必须要先有一个基础的函数模板
2. 关键字template后面接一对空的尖括号<>
3. 函数名后跟一对尖括号,尖括号中指定需要特化的类型
4. 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。
#include <iostream>
using namespace std;
template<class T>
bool IsEqual(T& left, T& right)
{
return left == right;
}
//函数模板特化
template<>
bool IsEqual<char*>(char*& left, char*& right)
{
return strcmp(left, right);
}
int main()
{
int a = 0, b = 1;
cout << IsEqual(a, b) << endl;
const char* p1 = "hello";
const char* p2 = "world";
cout << IsEqual(p1, p2) << endl;
const char* p3 = "abcdef";
const char* p4 = "abcdef";
cout << IsEqual(p3, p4) << endl;
return 0;
}
注意:一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出。
类模板特化
全特化:即是将模板参数列表中所有的参数都确定化。
偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。
偏特化有以下两种表现方式:
部分特化 :将模板参数类表中的一部分参数特化。
参数更进一步的限制:偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版本。
#include <iostream>
using namespace std;
template<class T1, class T2>
class Data
{
public:
Data() { cout << "Data<T1, T2>" << endl; }
private:
T1 _d1;
T2 _d2;
};
//类模板特化
//全特化(全部参数都特化)
template<>
class Data<int, char>
{
public:
Data() { cout << "全特化:Data<int, char>" << endl; }
private:
};
//偏特化
template<class T>
class Data<T, char>
{
public:
Data() { cout << "偏特化:Data<T, char>" << endl; }
private:
};
//偏特化指针
template<class T1,class T2>
class Data<T1*, T2*>
{
public:
Data() { cout << "偏特化:Data<T1*, T2*>" << endl; }
private:
};
//偏特化引用
template<class T1, class T2>
class Data<T1&, T2&>
{
public:
Data() { cout << "偏特化:Data<T1&, T2&>" << endl; }
private:
};
int main()
{
Data<int, int> d1;
Data<int, char> d2;
Data<short, char> d3;
Data<short*, char*> d4;
Data<short&, char&> d5;
return 0;
}
特化的总结
- 类模板特化:可以全特化和部分特化,允许为特定类型或条件提供不同的实现。
- 函数模板特化:支持全特化,但不支持部分特化。
- 用途:模板特化用于优化性能、调整行为或处理特定类型的情况。
模板分离编译
什么是分离编译
一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。
模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义。
但这样在链接的时候无法找到模板的定义。
解决方法
1. 将声明和定义放到一个文件 "xxx.hpp" 里面或者xxx.h其实也是可以的。推荐使用这种。
2. 模板定义的位置显式实例化。这种方法不实用,不推荐使用。
模板总结
【优点】
1. 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生
2. 增强了代码的灵活性
【缺陷】
1. 模板会导致代码膨胀问题,也会导致编译时间变长
2. 出现模板编译错误时,错误信息非常凌乱,不易定位错误
继承
在C++中,继承是一种重要的面向对象编程(OOP)特性,允许一个类(派生类)从另一个类(基类)派生出新的类。通过继承,派生类可以重用基类的属性和方法,并可以扩展或修改其功能。
1. 基本概念
- 基类(Base Class):被继承的类。
- 派生类(Derived Class):从基类派生的类。
- 访问修饰符:控制基类成员在派生类中的访问权限。
2. 继承的类型
C++ 支持多种类型的继承:
- 公有继承(Public Inheritance):派生类可以访问基类的公有成员。
- 保护继承(Protected Inheritance):派生类可以访问基类的公有和保护成员,但基类的公有成员在派生类中变为保护成员。
- 私有继承(Private Inheritance):派生类只能访问基类的保护成员,基类的公有成员在派生类中变为私有成员。
3. 示例代码
以下是一个简单的继承示例:
#include <iostream>
// 基类
class Animal {
public:
void speak() {
std::cout << "Animal speaks" << std::endl;
}
};
// 派生类
class Dog : public Animal {
public:
void bark() {
std::cout << "Dog barks" << std::endl;
}
};
int main() {
Dog dog;
dog.speak(); // 继承自基类的方法
dog.bark(); // 派生类的方法
return 0;
}
4. 重写(Override)
派生类可以重写基类的方法,以提供不同的实现:
class Animal {
public:
virtual void speak() { // 使用虚函数
std::cout << "Animal speaks" << std::endl;
}
};
class Dog : public Animal {
public:
void speak() override { // 重写基类的方法
std::cout << "Dog barks" << std::endl;
}
};
int main() {
Animal* animal = new Dog();
animal->speak(); // 输出 "Dog barks"
delete animal;
return 0;
}
5. 多重继承
C++ 还支持多重继承,允许一个派生类从多个基类继承:
class A {
public:
void methodA() {
std::cout << "Method A" << std::endl;
}
};
class B {
public:
void methodB() {
std::cout << "Method B" << std::endl;
}
};
class C : public A, public B {
public:
void methodC() {
std::cout << "Method C" << std::endl;
}
};
int main() {
C obj;
obj.methodA();
obj.methodB();
obj.methodC();
return 0;
}
6. 访问控制
在继承中,基类的成员的访问权限会影响派生类的访问性。例如:
class Base {
protected:
int protectedMember;
public:
Base() : protectedMember(10) {}
};
class Derived : public Base {
public:
void accessBase() {
std::cout << protectedMember << std::endl; // 可以访问
}
};
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保 持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象 程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
继承定义
在C++中,继承(Inheritance)是一种面向对象编程的特性,允许一个类(称为派生类或子类)从另一个类(称为基类或父类)派生出新的类。通过继承,派生类可以重用基类的属性(成员变量)和方法(成员函数),并且可以扩展或修改其功能。
继承的定义格式
class DerivedClassName : accessSpecifier BaseClassName {
// 派生类的成员变量和成员函数
};
组成部分
- DerivedClassName:派生类的名称。
- accessSpecifier:访问修饰符,可以是以下三种之一:
public
:公有继承,基类的公有成员在派生类中保持公有。protected
:保护继承,基类的公有和保护成员在派生类中变为保护成员。private
:私有继承,基类的公有和保护成员在派生类中变为私有成员。
- BaseClassName:基类的名称。
以下是关于 C++ 中不同继承方式下基类成员在派生类中的可见性的总结表:
类成员 / 继承方式 | 基类的 public 成员 | 基类的 protected 成员 | 基类的 private 成员 | 派生类的 public 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
---|---|---|---|---|---|---|
Public 继承 | 可见 | 可见 | 不可见 | 可见 | 可见 | 不可见 |
Protected 继承 | 不可见 | 可见 | 不可见 | 不可见 | 可见 | 不可见 |
Private 继承 | 不可见 | 不可见 | 不可见 | 不可见 | 不可见 | 不可见 |
总结
-
基类的 private 成员:
在派生类中,无论以何种方式继承,这些成员都是不可见的。 -
基类的 public 成员:
- 在 public 继承中,保持可见。
- 在 protected 继承中,变为 protected,不可被外部访问。
- 在 private 继承中,变为 private,不可被外部访问。
-
基类的 protected 成员:
- 在 public 继承中,保持可见。
- 在 protected 继承中,也保持可见。
- 在 private 继承中,变为不可见。
访问方式的变化
- Public 继承:基类的 public 和 protected 成员在派生类中保持可见,基类的 private 成员不可见。
- Protected 继承:基类的 public 成员变为 protected,protected 成员保持可见,private 成员不可见。
- Private 继承:所有基类成员(public、protected 和 private)在派生类中均不可见。
总结
-
基类的 private 成员不可见:
基类的 private 成员在派生类中无论以何种方式继承都是不可见的。虽然这些成员依然存在于派生类对象中,但语法上限制派生类对象在类内外都无法访问。 -
基类的 protected 成员:
如果需要在派生类中访问成员但不希望它们在类外被直接访问,可以将这些成员定义为 protected。protected 成员的访问权限是为了适应继承关系而设计的。 -
访问方式总结:
基类的 private 成员在派生类中始终不可见。其他成员在派生类中的访问权限取决于基类的访问限定符和继承方式的最小值:public > protected > private
。 -
默认继承方式:
使用class
时,默认的继承方式是 private;使用struct
时,默认的继承方式是 public。尽管如此,最好显式地指定继承方式,以提高代码的可读性。#include "template.h" using namespace std; #include <iostream> // 基类 class Base { public: void show() { std::cout << "Base class method" << std::endl; } }; // 使用 class 的派生类,默认继承方式为 private class DerivedFromClass : Base { // 默认是 private 继承 public: void display() { show(); } }; // 使用 struct 的派生类,默认继承方式为 public struct DerivedFromStruct : Base { // 默认是 public 继承 public: void display() { show(); // 正确:可以访问 Base 的 public 成员 } }; int main() { DerivedFromClass d1; //d1.show(); // 错误:无法访问 Base 的 public 成员 d1.display(); DerivedFromStruct d2; d2.show(); // 正确:可以访问 Base 的 public 成员 d2.display(); // 正确:调用派生类的方法 return 0; }
class ExplicitDerived : public Base { // 显式指定为 public 继承 public: void display() { show(); // 正确:可以访问 Base 的 public 成员 } };
-
实际运用:
在实际开发中,通常使用 public 继承。protected 和 private 继承的使用较少,不建议频繁使用,因为它们限制了成员的访问范围,降低了代码的扩展性和维护性。
基类和派生类对象赋值转换
在C++中,基类和派生类对象之间的赋值转换涉及到对象的类型兼容性。
1. 基类到派生类的赋值转换
当将基类对象赋值给派生类对象时,通常会出现类型不兼容的问题。基类对象无法直接赋值给派生类对象,因为派生类可能包含基类没有的成员。
示例:
class Base {
public:
void show() {
std::cout << "Base class" << std::endl;
}
};
class Derived : public Base {
public:
void display() {
std::cout << "Derived class" << std::endl;
}
};
int main() {
Base b;
Derived d;
// b = d; // 错误:无法将派生类对象赋值给基类对象
return 0;
}
2. 派生类到基类的赋值转换
将派生类对象赋值给基类对象是允许的,因为派生类对象包含基类的所有成员。此时只会复制基类部分,派生类特有的成员将被忽略。
示例:
int main() {
Derived d;
Base b;
b = d; // 正确:将派生类对象赋值给基类对象,只复制基类部分
b.show(); // 可以调用基类的方法
return 0;
}
3. 使用指针和引用
使用指针和引用可以更灵活地处理基类和派生类之间的转换。
3.1 基类指针指向派生类对象
可以将基类指针指向派生类对象,这样可以通过基类指针访问派生类的成员。
示例:
int main() {
Derived d;
Base* bPtr = &d; // 基类指针指向派生类对象
bPtr->show(); // 调用基类的方法
// 不能直接访问派生类的方法
// bPtr->display(); // 错误
return 0;
}
3.2 使用虚函数实现多态
通过使用虚函数,可以在基类中声明成员函数,让派生类重写这些函数,以实现多态性。
示例:
class Base {
public:
virtual void show() { // 虚函数
std::cout << "Base class" << std::endl;
}
};
class Derived : public Base {
public:
void show() override { // 重写
std::cout << "Derived class" << std::endl;
}
};
int main() {
Base* bPtr = new Derived(); // 基类指针指向派生类对象
bPtr->show(); // 输出 "Derived class"
delete bPtr; // 释放内存
return 0;
}
总结
- 基类到派生类:不允许直接赋值,因无法保证类型的兼容性。
- 派生类到基类:允许赋值,只复制基类部分。
- 使用指针和引用:可以灵活处理基类和派生类之间的转换,利用多态性增强程序的灵活性和可扩展性。
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片 或者切割。寓意把派生类中父类那部分切来赋值过去。
基类对象不能赋值给派生类对象。
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)的dynamic_cast 来进行识别后进行安全转换。
class Person
{
protected :
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public :
int _No ; // 学号
};
void Test ()
{
Student sobj ;
// 1.子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj ;
Person* pp = &sobj;
Person& rp = sobj;
//2.基类对象不能赋值给派生类对象
sobj = pobj;
// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj
Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
ps1->_No = 10;
pp = &pobj;
Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问
题
ps2->_No = 10;
}
继承中的作用域
1. 独立的作用域
在继承体系中,基类和派生类拥有独立的作用域。每个类都可以定义自己的成员,且这些成员在各自的作用域内有效。即使成员的名称相同,它们也不冲突,因为它们分别存在于不同的类的作用域中。
2. 成员隐藏
当派生类中定义了与基类同名的成员时,派生类的成员将隐藏基类的同名成员。这种现象称为隐藏或重定义。在派生类的成员函数中,可以使用基类的作用域解析运算符来访问被隐藏的成员。
示例:
#include <iostream>
class Base {
public:
void display() {
std::cout << "Base display" << std::endl;
}
};
class Derived : public Base {
public:
void display() { // 这个函数隐藏了 Base::display
std::cout << "Derived display" << std::endl;
}
void callBaseDisplay() {
Base::display(); // 显式访问基类的 display
}
};
int main() {
Derived d;
d.display(); // 输出: Derived display
d.callBaseDisplay(); // 输出: Base display
return 0;
}
3. 成员函数的隐藏
对于成员函数的隐藏,只要函数名相同,就会构成隐藏。这不依赖于参数的数量或类型。派生类可以重载基类的函数,但只要函数名相同,基类的同名函数就会被隐藏,无法直接访问。
示例:
#include "template.h"
using namespace std;
#include <iostream>
class Base {
public:
void show(int x) {
std::cout << "Base show with int: " << x << std::endl;
}
};
class Derived : public Base {
public:
void show(double x) { // 这个函数隐藏了 Base::show(int)
std::cout << "Derived show with double: " << x << std::endl;
}
};
int main() {
Derived d;
d.Base::show(10); // 显示调用 Base::show(int)
d.show(10.5); // 调用 Derived::show(double)
return 0;
}
4. 避免同名成员
在实际开发中,尽量避免在基类和派生类中定义同名的成员。这样可以减少代码的混淆,避免潜在的错误和维护困难。使用不同的名称可以提高代码的可读性和可维护性。
总结
- 独立作用域:基类和派生类拥有独立的作用域。
- 成员隐藏:派生类同名成员会隐藏基类的同名成员。可以使用
基类::成员
访问被隐藏的成员。 - 函数名隐藏:只要函数名相同,即使参数不同,基类的成员函数也会被隐藏。
- 最佳实践:在继承体系中,最好避免定义同名的成员,以提高代码的清晰度和可维护性。
#include "template.h"
using namespace std;
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected:
string _name = "张三"; // 姓名
int _num = 5166; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl;
cout << " 学号:" << _num << endl;
}
protected:
int _num = 10111; // 学号
};
void Test()
{
Student s1;
s1.Print();
};
int main()
{
Test();
return 0;
}
#include "template.h"
using namespace std;
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
A::fun();
cout << "func(int i)->" << i << endl;
}
};
void Test()
{
B b;
b.fun(10);
};
int main()
{
Test();
return 0;
}
派生类的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类 中,这几个成员函数是如何生成的呢?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。
7. 析构函数需要重写,重写的条件之一是处理后函数名相同。编译器会对析构函数名进行特殊处理,统一处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
#include "template.h"
using namespace std;
class Person
{
public:
Person(const char* name = "peter")
: _name(name)
{
cout << "Person()" << endl;
}
Person(const Person& p)
: _name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
Person& operator=(const Person& p)
{
cout << "Person operator = (const Person& p)" << endl;
if (this != &p)
_name = p._name;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
Student(const char* name, int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator = (const Student& s)
{
cout << "Student& operator = (const Student& s)" << endl;
if (this != &s)
{
Person::operator =(s);
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
void Test()
{
Student s1("jack", 18);
cout << "-------------------------\n";
Student s2(s1);
cout << "-------------------------\n";
Student s3("rose", 17);
cout << "-------------------------\n";
s1 = s3;
cout << "-------------------------\n";
}
int main()
{
Test();
return 0;
}
设计一个不能被继承的类
设计一个不能被继承的类可以通过以下几种方式实现:
1. 使用 final
关键字
将类声明为 final
,确保该类不能被任何其他类继承。
class NonInheritable final {
public:
NonInheritable() {}
void someMethod() {}
};
// 试图继承将导致编译错误
class Derived : public NonInheritable { // 编译错误
};
2. 将构造函数设为私有
将构造函数声明为私有,以防止外部代码创建该类的实例。这通常与友元函数或静态成员方法结合使用。
#include "template.h"
using namespace std;
class Singleton1
{
private:
Singleton1() {} // 私有构造函数
};
class Singleton2
{
private:
~Singleton2() {} // 私有析构函数
};
class A : public Singleton1
{
};
class B : public Singleton2
{
};
// 使用示例
int main()
{
A a;//报错 无法引用 "A" 的默认构造函数 -- 它是已删除的函数
B b;//报错 无法引用 "B" 的默认构造函数 -- 它是已删除的函数
return 0;
}
3. 禁止拷贝构造和赋值运算符
使用 delete
关键字禁止拷贝和赋值,以进一步确保类的唯一性。
#include "template.h"
using namespace std;
class NonInheritable {
public:
NonInheritable() {}
// 禁止拷贝和赋值
NonInheritable(const NonInheritable&) = delete;
NonInheritable& operator=(const NonInheritable&) = delete;
};
class Derived : public NonInheritable { // 不会编译错误
};
int main()
{
Derived s1;
Derived s2(s1); //报错 无法拷贝
s2 = s1; //报错 无法赋值
return 0;
}
总结
final
关键字:最直接的方法,明确表明类不能被继承。- 私有构造函数:防止类被实例化,适用于单例模式或控制对象创建。
- 禁止拷贝和赋值:进一步防止对象复制,增强类的封装性。
继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员 。
- 基类的友元函数或友元类只能访问基类的私有和保护成员。
#include "template.h"
using namespace std;
class Base {
private:
int baseValue;
public:
Base(int v) : baseValue(v) {}
// 声明友元函数
friend void showBaseValue(const Base& obj);
};
class Derived : public Base {
private:
int derivedValue;
public:
Derived(int v1, int v2) : Base(v1), derivedValue(v2) {}
// 声明友元函数
friend void showDerivedValue(const Derived& obj);
};
// 友元函数,访问 Base 类的私有成员
void showBaseValue(const Base& obj) {
cout << "Base Value: " << obj.baseValue << endl; // 可访问
}
// 友元函数,访问 Derived 类的私有成员
void showDerivedValue(const Derived& obj) {
cout << "Derived Value: " << obj.derivedValue << endl; // 可访问
// cout << "Base Value: " << obj.baseValue << endl; // 编译错误!无法访问 Base 的私有成员
}
int main() {
Base b(10);
Derived d(20, 30);
showBaseValue(b);
showDerivedValue(d);
return 0;
}
继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。
#include "template.h"
using namespace std;
class Person
{
public:
Person() { ++_count; }
public:
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
public:
int _stuNum;
};
void TestPerson()
{
Person p;
Student s;
cout << p._count << endl;
p._count = 20;
cout << s._count << endl;
s._count = 30;
cout << p._count << endl;
}
int main()
{
TestPerson();
return 0;
}
复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承有数据冗余和二义性的问题。
显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
在 C++ 中,菱形继承(Diamond Inheritance)指的是一个类通过两个不同的路径继承自同一个基类,这可能导致二义性问题。为了解决这个问题,C++ 引入了虚拟继承(Virtual Inheritance)的概念。以下是对这两种继承方式的详细解释和示例。
1. 菱形继承
结构:
A
/ \
B C
\ /
D
在这个结构中,类 B
和 C
都继承自类 A
,而类 D
继承自类 B
和 C
。这会导致 D
继承了两个 A
的实例。
问题:
- 二义性:如果
D
尝试访问A
的成员,编译器无法确定是通过B
还是C
来访问,导致二义性错误。
示例:
#include <iostream>
using namespace std;
class A {
public:
void show() {
cout << "Class A" << endl;
}
};
class B : public A {
public:
void show() {
cout << "Class B" << endl;
}
};
class C : public A {
public:
void show() {
cout << "Class C" << endl;
}
};
class D : public B, public C {
public:
void display() {
// cout << "D's A: " << A::show(); // 会导致编译错误:二义性
B::show(); // 明确调用 B 中的 show
C::show(); // 明确调用 C 中的 show
}
};
int main() {
D obj;
obj.display();
return 0;
}
2. 虚拟继承
解决方案:
通过使用虚拟继承,可以确保无论通过多少个路径,派生类只会有一个基类的实例。这可以消除二义性。
结构:
A
/ \
B C
\ /
D
使用虚拟继承时,B
和 C
都虚拟继承自 A
,D
只会有一个 A
的实例。
示例:
#include <iostream>
using namespace std;
class A
{
public:
void show()
{
cout << "Class A" << endl;
}
};
class B : virtual public A
{ // 虚继承
public:
void show1()
{
cout << "Class B" << endl;
}
};
class C : virtual public A
{ // 虚继承
public:
void show1()
{
cout << "Class C" << endl;
}
};
class D : public B, public C
{
public:
void display()
{
show(); // 直接调用,只有一个 A 的实例
}
};
int main()
{
D obj;
obj.display();
return 0;
}
总结
-
菱形继承:
- 通过多条路径继承同一个基类,可能导致二义性。
- 需要在使用时明确指定调用哪个基类的成员。
-
虚拟继承:
- 通过
virtual
关键字解决菱形继承的问题,确保只有一个基类的实例。 - 消除了二义性,使得访问基类的成员更加清晰。
- 通过
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。
虚拟继承解决数据冗余和二义性的原理
虚拟继承通过使用 virtual
关键字来解决上述问题。其主要原理如下:
-
共享基类实例:通过虚拟继承,所有派生类(如
B
和C
)都共享同一个基类的实例。这样,类D
只会有一个A
的实例,而不是两个。
虚基表中存的偏移量。通过偏移量 可以找到共享的A。 -
控制访问:虚拟继承确保在构造
D
时,基类A
的构造函数只会被调用一次。这意味着无论是从B
还是C
继承,D
都只会有一个A
的实例。
1. 虚基表的概念
-
虚基表(Virtual Base Table):虚基表是一个数据结构,存储了指向虚基类实例的指针。每个虚基类都有一个对应的虚基表,用于管理从该基类派生的对象的访问。
-
作用:
- 共享基类实例:在多重继承中,多个派生类可能尝试继承自同一个基类。虚基表确保所有派生类共享同一个基类实例,避免了冗余。
- 解决二义性:通过虚基表,派生类可以安全地访问虚基类的成员,消除访问时的二义性。
2. 虚基表指针
- 虚基表指针(Virtual Base Table Pointer):每个包含虚基类的派生类对象都会有一个指针,指向其虚基表。这是一个指向虚基表的地址,用于查找虚基类的实例。
3. 内存布局
当使用虚继承时,内存中的布局如下:
+---------------------+
| 虚基表指针 | // 指向虚基表
+---------------------+
| 其他成员变量 |
+---------------------+
4. 构造和析构过程
-
构造过程:
- 在创建一个派生类对象时,虚基类的构造函数首先被调用。构造函数会初始化虚基表指针,使其指向共享的虚基类实例。
- 例如,在构造派生类
D
时,首先会构造A
,然后是B
和C
,最后构造D
。虚基表指针会在构造B
和C
时被设置。
-
析构过程:
- 在销毁对象时,析构函数会按照相反的顺序被调用。派生类的析构函数在最底层被调用,最后调用虚基类的析构函数,以确保资源被正确释放。
继承的总结和反思
1. 继承的定义
继承是面向对象编程(OOP)的核心特性之一,它允许一个类(子类或派生类)从另一个类(父类或基类)获取属性和方法,从而实现代码的重用和层次结构的建立。
2. 继承的类型
- 单继承:一个子类只能有一个父类。简单易懂,容易实现。
- 多继承:一个子类可以有多个父类。这提供了更大的灵活性,但也带来了复杂性,可能导致菱形继承等问题。
- 虚拟继承:通过虚拟继承解决菱形继承问题,确保多个派生类共享同一个基类实例,避免数据冗余和二义性。
3. 继承的优点
- 代码重用:子类可以直接使用父类的属性和方法,减少代码重复。
- 扩展性:可以轻松扩展和修改系统功能,而无需改变已有的代码。
- 多态性:支持通过基类指针或引用调用派生类的重写方法,提高系统的灵活性。
4. 继承的缺点
- 复杂性:多重继承和虚拟继承可能导致类层次结构复杂,增加理解和维护的难度。
- 紧耦合:子类与父类之间的紧密联系可能导致代码脆弱,父类的改动可能影响所有子类。
- 菱形继承问题:多重继承中可能出现二义性,需要额外机制(如虚拟继承)来解决。
5. 反思与最佳实践
-
优先使用组合:在设计时,优先考虑使用对象组合而非继承。组合带来的低耦合性有助于提高代码的可维护性和灵活性。
-
适当使用继承:虽然组合更为灵活,但在某些情况下,继承是合适的,特别是在需要实现多态时。选择继承时,应明确类之间的关系是否符合“is-a”模型。
-
关注封装性:继承可能破坏基类的封装性,因此在设计基类时应尽量减少对其内部实现的依赖,保持良好的接口设计。
-
文档和注释:对于复杂的继承结构,提供充分的文档和注释,以帮助其他开发人员理解设计意图。
1. C++ 语法复杂性与多继承
-
多继承的复杂性:多继承确实是 C++ 语法复杂性的一个体现。引入多继承后,出现了菱形继承问题,即多个路径可以访问同一基类实例。这会导致二义性,编译器无法确定应该调用哪个基类的方法。
-
菱形虚拟继承:为了解决菱形继承的问题,引入了虚拟继承。虽然虚拟继承可以解决共享基类的问题,但其底层实现复杂,增加了内存开销和性能成本。
-
建议:因此,在设计时,应尽量避免多继承,尤其是菱形继承,以降低复杂度和潜在的性能问题。
2. 多继承的缺陷
- 缺陷之一:多继承可以被视为 C++ 的一个缺陷,因为它引入了复杂性和潜在的错误。相比之下,许多现代面向对象语言(如 Java)选择不支持多继承,采用接口等其他机制来实现多态。
3. 继承与组合
-
继承的关系:继承是一种“is-a”关系,表示派生类是基类的一种特化。例如,
Dog
继承自Animal
,表示“狗是一种动物”。 -
组合的关系:组合是一种“has-a”关系,表示一个类包含另一个类的实例。例如,
Car
可能包含一个Engine
,表示“车有一个引擎”。
4. 赋值与查找虚基类
- 查找虚基类实例:在多继承的情况下,子类(如
D
)可能会访问其父类(B
和C
)中的基类(A
)。当进行赋值操作时,派生类D
需要能够找到其父类中的A
实例,以便正确进行赋值。
5. 代码复用的选择
-
白箱复用:通过继承实现的复用被称为白箱复用,基类的实现细节对子类可见。这种方式虽然便于复用,但也破坏了封装性,导致基类的修改可能对所有子类造成影响。
-
黑箱复用:对象组合则是一种黑箱复用,组合的对象只暴露接口,其内部实现细节对外不可见。这降低了类之间的耦合度,增强了系统的灵活性和可维护性。
6. 实践建议
-
优先使用组合:在设计时,优先考虑使用对象组合而不是继承。组合的耦合度低,维护性好,能够更好地实现模块化设计。
-
适当使用继承:虽然组合有其优势,但在某些情况下,继承仍然是合适的,尤其是在需要实现多态时。应根据具体的关系选择合适的方式。
7. 总结
在 C++ 中,继承和组合都是重要的设计工具。合理选择它们可以提高代码的可读性、可维护性和可重用性。应当牢记:
- 避免复杂的多继承,尤其是菱形继承。
- 在设计时优先考虑对象组合,保持低耦合。
- 在需要实现特定关系或多态时,适当使用继承。
继承和组合
在C++中,继承和组合是两种重要的面向对象编程(OOP)概念,用于实现代码的重用和扩展。它们的使用场景和优缺点各有不同。
继承
定义
继承是一种机制,通过它一个类可以继承另一个类的属性和方法。被继承的类称为基类(或父类),继承的类称为派生类(或子类)。
特点
- 代码重用: 子类可以重用父类的代码。
- 多态性: 可以通过基类指针调用派生类的重写方法。
- 层次结构: 允许创建类的层次结构。
示例
class Base {
public:
void display() {
std::cout << "Base class" << std::endl;
}
};
class Derived : public Base {
public:
void display() {
std::cout << "Derived class" << std::endl;
}
};
组合
定义
组合是将一个类的对象作为另一个类的成员变量,从而形成“拥有”关系。组合表示一个类是另一个类的一部分。
特点
- 灵活性: 可以更改组合的对象,而不影响组合类的其他部分。
- 低耦合: 通过组合可以减少类之间的依赖。
- 增强可复用性: 可以将小的类组合成更复杂的类。
示例
class Engine {
public:
void start() {
std::cout << "Engine starting" << std::endl;
}
};
class Car {
private:
Engine engine; // 组合关系
public:
void start() {
engine.start();
std::cout << "Car starting" << std::endl;
}
};
继承 vs 组合
特点 | 继承 | 组合 |
---|---|---|
关系 | “是一个”(is-a) | “有一个”(has-a) |
重用方式 | 通过父类方法和属性 | 通过组合对象的方法和属性 |
灵活性 | 较低,父类的改变会影响子类 | 较高,独立对象可独立修改 |
设计复杂度 | 可能导致复杂的层次结构 | 更加简单清晰 |
总结
- 继承适合用于表示类之间的层次关系,而组合更适合用于表示类之间的拥有关系。
- 在实际编程中,选择继承还是组合应根据需求和设计考虑,合理使用可以提高代码的可读性和可维护性。
多态
多态是面向对象编程(OOP)中的一个核心概念,允许对象以多种形式出现。
多态的类型
-
编译时多态(静态多态):
- 函数重载: 允许同名函数根据参数类型或数量的不同而具有不同的实现。
- 运算符重载: 自定义运算符的行为,使其与用户定义的类型兼容。
示例代码
class Math { public: int add(int a, int b) { return a + b; } double add(double a, double b) { return a + b; } };
-
运行时多态(动态多态):
- 通过虚函数实现,允许程序在运行时根据对象的实际类型调用相应的函数实现。
示例代码:
class Base { public: virtual void show() { cout << "Base class" << endl; } }; class Derived : public Base { public: void show() override { cout << "Derived class" << endl; } }; void display(Base* b) { b->show(); // 动态绑定 }
多态的原理
在 C++ 中,运行时多态是通过虚函数机制实现的,涉及以下几个要素:
-
虚函数表(VTable):
- 每个包含虚函数的类都有一个虚函数表,表中存储指向虚函数的指针。
-
虚指针(vptr):
- 每个对象实例都有一个指向其类的虚函数表的指针,称为虚指针。
-
动态绑定:
- 当通过基类指针或引用调用虚函数时,程序会根据对象的实际类型在运行时查找虚函数表,从而调用相应的实现。
多态的优点
- 代码复用: 通过基类接口可以处理多种派生类的对象,减少代码重复。
- 灵活性: 可以在运行时根据实际类型选择执行的代码,提高了程序的灵活性和可扩展性。
- 可维护性: 更容易对代码进行维护和扩展,因为新类型可以通过继承和重写虚函数来添加。
多态的使用场景
- 接口设计: 通过基类定义公共接口,派生类实现具体功能。
- 事件处理: 在 GUI 应用程序中,事件处理可以通过多态来实现不同控件的响应。
- 算法和数据结构: 可以通过多态来处理不同类型的对象,简化算法的实现。
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。
多态的定义及实现
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
虚函数
虚函数是基类中声明为 virtual
的成员函数。它允许在派生类中重写(override)该函数,从而实现基类指针或引用可以调用派生类的实现。
虚函数:即被virtual修饰的类成员函数称为虚函数。
class Person {
public:
virtual void BuyTicket() { cout << "全价票" << endl;}
};
基本概念
- 多态性: 虚函数使得对象的行为可以在运行时动态决定,从而支持多态性。
- 虚表(VTable): 每个包含虚函数的类都有一个虚表,虚表中保存了指向该类的虚函数的指针。每个对象实例也有一个指向虚表的指针(通常称为虚指针,vptr)。
- 重写: 派生类可以重写基类中的虚函数,提供自己的实现。
- 普通函数不能被声明为虚函数,因为它们不支持动态绑定的机制,且设计上不需要这种特性。
- 如果需要多态行为,应使用虚函数;如果不需要,可以使用普通函数。
虚函数的重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
虚函数的重写是 C++ 中实现多态性的一个重要概念。重写(override)是指在派生类中提供对基类中虚函数的新实现。以下是关于虚函数重写的详细解释。
在派生类中重写虚函数时,通常在函数声明的前面加上 override
关键字,以提高代码的可读性并防止错误。
示例代码
以下是一个简单的示例,展示如何在 C++ 中重写虚函数:
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() { // 基类中的虚函数
cout << "Base class show function." << endl;
}
virtual ~Base() {} // 虚析构函数
};
class Derived : public Base {
public:
void show() override { // 重写基类的虚函数
cout << "Derived class show function." << endl;
}
};
int main() {
Base* b = new Derived(); // 基类指针指向派生类对象
b->show(); // 调用派生类的 show 函数
delete b; // 清理内存
return 0;
}
重写的注意事项
- 函数签名: 在重写虚函数时,派生类中函数的返回类型、参数类型和数量必须与基类中的虚函数完全相同(包括 const 限定符)。
- 使用
override
关键字: 虽然不是必须的,但使用override
可以帮助编译器检查是否正确重写了基类的虚函数。 - 虚析构函数: 如果类中有虚函数,建议将析构函数也声明为虚函数,以确保派生类的析构函数在删除基类指针时被正确调用。
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因
为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议
这样使用*/
/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p){ p.BuyTicket(); }
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
虚函数重写的两个例外:
协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class A{};
class B : public A {};
class Person {
public:
virtual A* f() {return new A;}
};
class Student : public Person {
public:
virtual B* f() {return new B;}
};
析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字, 都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同, 看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
#include <iostream>
using namespace std;
class Person {
public:
virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
C++11 override 和 final
C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
1. final:修饰虚函数,表示该虚函数不能再被重写
class Base {
public:
virtual void func() final
{ // 不能被重写
std::cout << "Base func" << std::endl;
}
};
class Derived : public Base
{
public:
// void func() override { // 编译错误,无法重写
// std::cout << "Derived func" << std::endl;
// }
};
2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Base
{
public:
virtual void func()
{
std::cout << "Base func" << std::endl;
}
};
class Derived : public Base {
public:
void func() override
{ // 正确重写基类的 func
std::cout << "Derived func" << std::endl;
}
// void func(int) override
// { // 编译错误,参数不匹配
// std::cout << "Derived func with int" << std::endl;
// }
};
抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生 类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
抽象类是 C++ 中用于实现接口和强制子类提供特定实现的机制。抽象类不能被实例化,只能作为其他类的基类。它通常包含一个或多个纯虚函数。
1. 定义
- 抽象类: 包含至少一个纯虚函数的类。纯虚函数是指在类中声明但没有实现的虚函数,形式如下:
virtual ReturnType FunctionName(Parameters) = 0;
2. 特性
- 不能实例化: 抽象类不能被直接创建对象。
- 包含纯虚函数: 至少有一个纯虚函数。
- 可以有其他成员: 除了纯虚函数,抽象类还可以包含非虚函数、成员变量和构造函数等。
3. 使用场景
- 接口定义: 抽象类用于定义一个接口,强制派生类实现特定的方法。
- 代码组织: 提供一种方式来组织和管理代码,特别是在需要多个类共享某种行为时。
4. 注意事项
- 如果一个类包含纯虚函数,那么它就必须被声明为抽象类。
- 抽象类可以包含实现了的虚函数,派生类可以选择重用这些方法。
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
1. 接口继承
- 定义: 接口继承主要指通过抽象类(包含纯虚函数)来定义接口。子类实现这些接口,但不需要继承任何具体的实现。
- 目的: 提供一个统一的接口,强制派生类实现特定的功能,而不关心具体实现的细节。
示例
class IShape {
public:
virtual void draw() = 0; // 纯虚函数
virtual double area() = 0; // 纯虚函数
};
class Circle : public IShape {
public:
void draw() override {
// 实现绘制圆的逻辑
}
double area() override {
// 实现计算圆的面积
return 0.0; // 示例返回值
}
};
2. 实现继承
- 定义: 实现继承是指子类继承父类的实现细节,可以直接使用父类的方法和属性,而不需要重新实现。
- 目的: 通过继承现有的实现来复用代码,减少重复,提供更具体的实现。
示例
class Shape {
public:
void draw() {
// 绘制逻辑
}
};
class Circle : public Shape {
public:
// Circle 可以直接使用 Shape 的 draw() 方法
double area() {
// 实现计算圆的面积
return 0.0; // 示例返回值
}
};
3. 区别
特性 | 接口继承 | 实现继承 |
---|---|---|
目的 | 定义规范,强制实现 | 代码复用,继承实现 |
纯虚函数 | 是 | 否 |
实例化 | 不能 | 可以 |
关注点 | 方法签名与行为 | 具体实现细节 |
4. 使用场景
- 接口继承: 常用于需要定义公共协议或标准的场景,如设计模式中的策略模式、观察者模式等。
- 实现继承: 常用于需要扩展现有类功能的场景,通常用在实现通用功能的基础类中。
总结
- 接口继承强调的是定义统一的接口,允许不同的类有不同的实现。
- 实现继承强调的是代码的复用,允许子类直接使用父类的实现,减少重复代码。
多态的原理
虚函数表
#include <iostream>
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
int main()
{
Base b;
cout << sizeof b;//x86: 8 x64: 16
return 0;
}
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
虚函数表(Vtable)是 C++ 中实现多态性的重要机制,帮助程序在运行时决定调用哪个虚函数。
1. 定义
- 虚函数表: 是一个由指向虚函数的指针组成的表。每个包含虚函数的类都有一个虚函数表,用于存储该类和其基类的虚函数地址。
2. 工作原理
- 当一个类声明了虚函数时,编译器为该类生成一个虚函数表,并在该表中存储所有虚函数的指针。
- 每个对象(或类的实例)会有一个指向其类的虚函数表的指针,称为虚指针(vptr)。
- 在运行时,当通过基类指针或引用调用虚函数时,程序会查找该对象的虚函数表,找到具体的函数地址并调用它。
1. 派生类对象的结构
- 派生类对象: 由两部分组成:
- 父类部分: 包含从基类继承的成员变量和虚表指针。
- 自己部分: 包含派生类自己定义的成员变量和函数。
2. 虚表与重写/覆盖
- 虚表的差异: 基类对象和派生类对象的虚表是不同的。
- 当派生类重写基类的虚函数(如
Func1
),派生类的虚表中将包含指向Derived::Func1
的指针。 - 重写: 语法层面的概念,指的是派生类中对基类虚函数的实现。
- 覆盖: 原理层面的概念,指的是在虚表中用派生类的函数替换基类的函数。
- 当派生类重写基类的虚函数(如
3. 虚函数的存放
- 虚函数的存放:
- 虚函数,它的指针会存放在派生类的虚表中。
- 不是虚函数,不会出现在虚表中。
4. 虚函数表的结构
- 虚函数表: 本质上是一个存储虚函数指针的数组,通常最后一个元素是
nullptr
,用于指示数组的结束。
5. 派生类虚表的生成
总结派生类虚表的生成过程:
- 拷贝基类虚表: 将基类的虚表内容复制到派生类的虚表中。
- 重写函数覆盖: 如果派生类重写了基类中的虚函数,则用派生类自己的函数覆盖虚表中的基类函数指针。
- 新增虚函数: 派生类自己新增的虚函数按声明顺序添加到虚表的末尾。
6. 虚函数与虚表的存放位置
- 虚函数: 存放在代码段,和普通函数一样。它们的实现是共享的,不会因对象的不同而改变。
- 虚表: 存放在静态内存区域,通常在程序的代码段中。
- 虚表指针: 虚表指针(vptr)存储在对象的内存中,指向该对象的虚表。
动态绑定与静态绑定
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载。
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
单继承和多继承关系的虚函数表
#include <iostream> // 引入输入输出流库
using namespace std; // 使用标准命名空间
// 定义基类 Base
class Base
{
public:
// 虚函数 func1,允许派生类重写
virtual void func1() { cout << "Base::func1" << endl; }
// 虚函数 func2,允许派生类重写
virtual void func2() { cout << "Base::func2" << endl; }
private:
int _a; // 私有成员变量
};
// 定义派生类 Derive,继承自 Base
class Derive : public Base
{
public:
// 重写基类的虚函数 func1
void func1() override { cout << "Derive::func1" << endl; }
// 新增虚函数 func3
virtual void func3() { cout << "Derive::func3" << endl; }
// 新增虚函数 func4
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int _b; // 私有成员变量
};
// 定义虚函数指针类型
typedef void(*VF_PTR)(); // VF_PTR 是指向返回类型为 void 且没有参数的函数的指针类型
// 打印虚函数表的函数
void PrintVTable(VF_PTR* pTable)
{
// 遍历虚函数表,直到遇到 nullptr
for (int i = 0; pTable[i] != nullptr; ++i)
{
// 打印虚函数表中的每个函数指针
printf("vTable[%d]: %p->", i, (void*)pTable[i]);
// 将当前函数指针赋值给 f
VF_PTR f = pTable[i];
// 调用当前函数
f();
}
cout << endl; // 输出换行
}
int main()
{
// 创建 Base 类的对象
Base b;
// 创建 Derive 类的对象
Derive d;
// 获取 Base 对象的虚函数表指针
VF_PTR* baseVTable = *(VF_PTR**)&b;
// 获取 Derive 对象的虚函数表指针
VF_PTR* deriveVTable = *(VF_PTR**)&d;
// 打印 Base 类的虚函数表
PrintVTable(baseVTable);
// 打印 Derive 类的虚函数表
PrintVTable(deriveVTable);
return 0; // 程序结束
}
代码详解
typedef void(*VF_PTR)();
定义了一个函数指针类型。
关键组成部分
-
typedef
:typedef
是一个关键字,用于创建类型的别名,使得代码更简洁、可读性更高。 -
void(*)()
:void
表示这个函数没有返回值。(*)
表示这是一个指向函数的指针。()
表示该函数没有参数。
-
这是为这个函数指针类型定义的别名。之后在代码中可以用VF_PTR
:VF_PTR
来代替void(*)()
。
整体含义
VF_PTR
是一个指向返回类型为void
且没有参数的函数的指针类型。换句话说,任何符合这个签名的函数都可以通过VF_PTR
类型的指针来引用。
1. VF_PTR* baseVTable = *(VF_PTR**)&b;
-
(VF_PTR**)&b
:- 这里我们将对象
b
的地址(&b
)转换为VF_PTR**
类型。 - 这个转换是告诉编译器,我们将
b
视为一个指向指针的指针,即希望访问b
的虚函数表指针。
- 这里我们将对象
-
*(VF_PTR**)&b
:- 这个操作取出了指向虚函数表的指针。
- 由于
b
是一个Base
类的对象,*(VF_PTR**)&b
实际上返回的是b
的虚函数表指针,即指向Base
类的虚函数表的地址。
-
VF_PTR* baseVTable
:- 最终,
baseVTable
成为一个指向函数指针的指针,指向Base
类的虚函数表。
- 最终,
2. VF_PTR* deriveVTable = *(VF_PTR**)&d;
-
(VF_PTR**)&d
:- 与上面的操作类似,这里我们将对象
d
的地址(&d
)转换为VF_PTR**
类型,目的是访问d
的虚函数表指针。
- 与上面的操作类似,这里我们将对象
-
*(VF_PTR**)&d
:- 这个操作取出了
d
的虚函数表指针。 - 因为
d
是Derive
类的对象,*(VF_PTR**)&d
返回的是d
的虚函数表指针,也就是指向Derive
类的虚函数表的地址。
- 这个操作取出了
-
VF_PTR* deriveVTable
:- 最终,
deriveVTable
成为一个指向函数指针的指针,指向Derive
类的虚函数表。
- 最终,
1. 每个父类都有自己的虚表
- 虚表的定义:每个包含虚函数的类都会生成一个虚函数表(vtable),该表中存储指向该类的虚函数的指针。
- 父类的虚表:每个父类都有独立的虚表。这意味着如果一个类继承自多个父类,那么每个父类都会有自己的虚表。
2. 子类的成员函数被放到了第一个父类的表中
- 虚函数的重写:当子类重写父类的虚函数时,子类的函数指针会替代父类虚表中的相应指针。通常情况下,编译器会将子类的虚函数放在第一个父类的虚表中。
- 示例:如果
Derived
类重写了Base
类的虚函数f()
,则在Derived
的虚表中,f()
的指针会替代在Base
的虚表中对应的指针。
3. 内存布局中,其父类布局依次按声明顺序排列
- 内存布局:在内存中,类的成员变量和虚表指针(vptr)会按照类定义的顺序存储。对于多重继承,父类的内存布局会按照声明顺序排列。
- 示例:如果
Derived
类继承自Base1
和Base2
,则Base1
的成员会在Base2
的成员之前。
4. 每个父类的虚表中的 f()
函数都被 overwrite 成了子类的 f()
- 函数覆盖:当子类重写父类的虚函数时,所有相关父类的虚表中的函数指针都会指向子类的实现。这是为了确保无论使用哪种父类指针,调用的都是子类的实现。
- 多态性:这种机制使得不同父类类型的指针能够指向同一个子类实例,并能调用到实际的子类函数,从而实现运行时多态性。
1. 什么是多态?
答:多态是指在不同情况下,函数或操作能够以不同的形式表现。C++ 中的多态主要通过虚函数实现,允许子类重写父类的虚函数,从而在运行时根据对象的实际类型调用相应的函数。
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
-
重载:在同一个作用域中,允许多个函数具有相同的名称,但参数列表不同(参数类型或数量不同)。例如:
void func(int x); void func(double y);
-
重写(覆盖):子类重新定义父类中的虚函数,具有相同的名称和参数列表。子类的实现会覆盖父类的实现。例如:
class Base { virtual void func(); }; class Derived : public Base { void func() override; // 重写 };
-
重定义(隐藏):在派生类中定义一个与基类中同名的非虚函数,覆盖基类中的函数,但不使用虚函数机制。这会隐藏基类中的同名函数。例如:
class Base { void func(); }; class Derived : public Base { void func(); // 重定义,隐藏基类的 func() };
3. 多态的实现原理?
答:多态的实现原理主要依赖于虚函数表(vtable)和虚函数指针(vptr)。每个包含虚函数的类都有一个虚表,存储了该类的虚函数指针。对象的虚指针指向其所属类的虚表。在运行时,通过虚指针查找和调用相应的函数实现,允许实现动态绑定。
4. inline函数可以是虚函数吗?
答:可以,但编译器会忽略 inline
属性,虚函数必须放入虚函数表中,因此无法在编译时完成内联展开。
5. 静态成员可以是虚函数吗?
答:不能。静态成员函数没有 this
指针,无法访问虚函数表,也就无法被视为虚函数。
6. 构造函数可以是虚函数吗?
答:不能。构造函数的虚函数表指针在构造函数初始化列表阶段才会被初始化,这意味着在构造函数执行期间,虚函数机制尚未可用。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,且最好将基类的析构函数定义为虚函数。这是为了确保在通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,从而避免资源泄漏。
8. 对象访问普通函数快还是虚函数更快?
答:对于普通对象,调用普通函数和虚函数的速度是相似的。但对于通过指针或引用调用的对象,普通函数调用更快,因为虚函数调用需要通过虚函数表进行查找,增加了开销。
9. 虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段生成的,通常存储在代码段(常量区)。每个包含虚函数的类都对应一个虚函数表。
10. C++菱形继承的问题?虚继承的原理?
答:菱形继承指的是一个类通过两个路径继承自同一个基类,可能导致基类的多个实例和二义性。虚继承通过在基类前加 virtual
关键字,确保只有一个基类实例,解决了这个问题。注意不要混淆虚函数表和虚基表。
11. 什么是抽象类?抽象类的作用?
答:抽象类是包含至少一个纯虚函数的类,无法实例化。抽象类的作用是定义接口,并强制派生类实现特定的功能,从而体现接口继承关系。使用抽象类可以实现更好的模块化和代码重用。
二叉搜索树
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树
1. 二叉搜索树的查找
a、从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找。
b、最多查找高度次,走到到空,还没找到,这个值不存在。
最坏情况下,查找的时间复杂度为 O(h),其中 h 是树的高度。对于平衡树,平均时间复杂度为 O(log n)。
2. 二叉搜索树的插入
a. 树为空,则直接新增节点,赋值给root指针
b. 如果树不为空,按照二叉搜索树的特性查找合适的插入位置:
- 如果新值小于当前节点的值,继续在左子树中查找。
- 如果新值大于当前节点的值,继续在右子树中查找。
3.二叉搜索树的删除
首先查找元素是否在二叉搜索树中,如果不存在,则返回。
否则要删除的结点可能分下面四种情况:
a. 要删除的结点无孩子结点
b. 要删除的结点只有左孩子结点
c. 要删除的结点只有右孩子结点
d. 要删除的结点有左、右孩子结点
看起来有待删除节点有4中情况,实际情况a可以与情况b或者c合并起来,因此真正的删除过程如下:
情况b:删除该结点且使被删除节点的双亲结点指向被删除节点的左孩子结点--直接删除
情况c:删除该结点且使被删除节点的双亲结点指向被删除结点的右孩子结点--直接删除
情况d:在它的右子树中寻找中序下的第一个结点(关键码最小),用它的值填补到被删除节点中,再来处理该结点的删除问题--替换法删除
二叉搜索树的实现
#pragma once
#include <iostream>
using namespace std;
// 定义二叉搜索树节点
template<class K>
struct BSTreeNode // Binary Search Tree Node
{
BSTreeNode<K>* _left; // 指向左子节点的指针
BSTreeNode<K>* _right; // 指向右子节点的指针
K _key; // 节点的键值
// 构造函数
BSTreeNode(const K& key)
: _left(nullptr), _right(nullptr), _key(key) {}
};
// 定义二叉搜索树类
template<class K>
class BSTree // Binary Search Tree
{
typedef BSTreeNode<K> Node; // 定义节点类型别名
public:
// 构造函数,初始化根节点为空
BSTree() : _root(nullptr) {}
// 析构函数,销毁树以释放内存
~BSTree() { DestroyTree(_root); }
// 插入键值
bool Insert(const K& key)
{
// 如果树为空,创建根节点
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
Node* parent = nullptr; // 用于跟踪当前节点的父节点
Node* cur = _root; // 从根节点开始遍历
while (cur)
{
if (cur->_key < key) // 当前值小于插入值,向右子树移动
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key > key) // 当前值大于插入值,向左子树移动
{
parent = cur;
cur = cur->_left;
}
else // 找到重复值,不插入
{
return false;
}
}
// 创建新节点并将其链接到适当的父节点
cur = new Node(key);
if (parent->_key < key) // 插入到右子树
{
parent->_right = cur;
}
else // 插入到左子树
{
parent->_left = cur;
}
return true;
}
// 删除给定键值
bool Erase(const K& key)
{
Node* parent = nullptr; // 用于跟踪当前节点的父节点
Node* cur = _root; // 从根节点开始遍历
while (cur)
{
if (cur->_key < key) // 当前值小于要删除的值,向右子树移动
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key > key) // 当前值大于要删除的值,向左子树移动
{
parent = cur;
cur = cur->_left;
}
else // 找到要删除的节点
{
// 处理删除逻辑
if (cur->_left == nullptr && cur->_right == nullptr) // 无子节点
{
// 更新父节点的指针
if (parent)
{
if (parent->_left == cur) // 如果当前节点是左子节点
parent->_left = nullptr;
else // 如果当前节点是右子节点
parent->_right = nullptr;
}
else // 删除根节点
{
_root = nullptr;
}
delete cur; // 释放当前节点内存
}
else if (cur->_left == nullptr) // 只有右子节点
{
if (parent)
{
if (parent->_left == cur)
parent->_left = cur->_right; // 连接父节点的左指针
else
parent->_right = cur->_right; // 连接父节点的右指针
}
else // 删除根节点
{
_root = cur->_right; // 根节点更新为右子节点
}
delete cur; // 释放当前节点内存
}
else if (cur->_right == nullptr) // 只有左子节点
{
if (parent)
{
if (parent->_left == cur)
parent->_left = cur->_left; // 连接父节点的左指针
else
parent->_right = cur->_left; // 连接父节点的右指针
}
else // 删除根节点
{
_root = cur->_left; // 根节点更新为左子节点
}
delete cur; // 释放当前节点内存
}
else // 有两个子节点
{
// 找到右子树中的最小节点
Node* rightMinParent = cur;
Node* rightMin = cur->_right;
while (rightMin->_left) // 遍历到右子树的最小节点
{
rightMinParent = rightMin;
rightMin = rightMin->_left;
}
// 替换当前节点的值为右子树最小节点的值
cur->_key = rightMin->_key;
// 更新右子树最小节点的父节点指针
if (rightMinParent->_left == rightMin)
{
rightMinParent->_left = rightMin->_right; // 删除最小节点
}
else
{
rightMinParent->_right = rightMin->_right; // 删除最小节点
}
delete rightMin; // 释放右子树最小节点内存
}
return true; // 成功删除
}
}
return false; // 没找到要删除的节点
}
// 查找键值是否存在
bool Find(const K& key)
{
Node* cur = _root; // 从根节点开始遍历
while (cur)
{
if (cur->_key < key) // 当前值小于要查找的值,向右子树移动
{
cur = cur->_right;
}
else if (cur->_key > key) // 当前值大于要查找的值,向左子树移动
{
cur = cur->_left;
}
else // 找到目标值
{
return true; // 找到
}
}
return false; // 没找到
}
// 中序遍历
void _InOrder(Node* root)
{
if (root == nullptr)
return; // 如果节点为空,返回
_InOrder(root->_left); // 先遍历左子树
cout << root->_key << " "; // 输出当前节点的值
_InOrder(root->_right); // 再遍历右子树
}
// 对外提供的中序遍历接口
void InOrder()
{
_InOrder(_root); // 从根节点开始遍历
cout << endl; // 输出换行
}
private:
Node* _root; // 根节点指针
// 递归销毁树
void DestroyTree(Node* node)
{
if (node)
{
DestroyTree(node->_left); // 先销毁左子树
DestroyTree(node->_right); // 再销毁右子树
delete node; // 最后删除当前节点
}
}
};
// 测试函数
void TestBSTree()
{
BSTree<int> t; // 创建一个整数类型的二叉搜索树
int a[] = { 5, 3, 4, 1, 7, 8, 2, 6, 0, 9 }; // 测试数据
for (auto e : a)
{
t.Insert(e); // 插入测试数据
}
t.InOrder(); // 输出中序遍历结果
t.Erase(2); // 删除节点 2
t.InOrder(); // 输出中序遍历结果
t.Erase(8); // 删除节点 8
t.Erase(1); // 删除节点 1
t.InOrder(); // 输出中序遍历结果
t.Erase(5); // 删除节点 5
t.InOrder(); // 输出中序遍历结果
for (auto e : a)
{
if(t.Erase(e))
t.InOrder();
}
}
二叉搜索树的应用
1. K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。 比如:给一个单词word,判断该单词是否拼写正确,具体方式如下: 以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树,在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
2. KV模型:每一个关键码key,都有与之对应的值Value,即的键值对。该种方式在现实生活中非常常见: 比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英 文单词与其对应的中文就构成一种键值对; 再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是就构成一种键值对。
二叉搜索树(Binary Search Tree, BST)是一种常用的数据结构,具有许多实际应用。以下是一些常见的应用场景:
1. 数据存储与检索
- 快速查找:BST 允许在平均 O(log n) 的时间复杂度内进行查找操作,适合需要频繁查找的场景。
- 有序存储:通过中序遍历,BST 可以以升序或降序输出所有元素,适合有序数据的存储。
2. 动态集合
- 集合操作:BST 可以实现集合的基本操作,如插入、删除和查找,适用于需要动态添加和删除元素的应用。
- 范围查询:可以快速找到在某个范围内的所有元素,这在数据库和搜索引擎中非常有用。
3. 数据库实现
- 索引:许多数据库使用 B 树或其变种(如 B+ 树)作为索引结构,这与 BST 的原理相似,有助于快速检索数据。
4. 优先队列
- 堆实现:虽然通常使用堆实现优先队列,但 BST 也可以用于实现优先队列,支持动态插入和删除操作。
5. 语言处理
- 语法树:在编译器中,源代码的语法树通常是用 BST 或其变种表示,以便进行语法分析和优化。
6. 动态规划
- 状态压缩:在某些动态规划算法中,BST 可用于存储和管理状态,以便快速访问和更新。
7. 频率统计
- 词频统计:在文本处理中,BST 可以用来统计单词出现的频率,通过单词作为键,频率作为值进行存储。
8. 版本控制
- 历史记录管理:在版本控制系统中,可以使用 BST 存储版本信息,方便进行增量更新和版本查找。
9. 备份与恢复
- 数据备份:使用 BST 可实现数据的增量备份和恢复,便于管理和查询备份数据。
二叉搜索树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二 叉搜索树的深度的函数,即结点越深,则比较次数越多。 但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树。
二叉搜索树(Binary Search Tree, BST)的性能分析主要涉及其时间复杂度和空间复杂度。以下是对 BST 性能的详细分析,包括平均情况、最坏情况和空间复杂度的讨论。
1. 最优情况:完全二叉树或接近完全二叉树
-
定义:在完全二叉树中,每一层都被完全填满,只有最后一层可能不满,并且最后一层的节点都集中在左侧。
-
比较次数:
- 在这种情况下,树的高度为 h=log2N,其中 N 是节点总数。
- 查找、插入和删除操作的平均比较次数为 O(log2N)。
- 这意味着在最优情况下,操作是非常高效的,因为每次比较都能有效地减少搜索空间。
2. 最差情况:退化为单支树
-
定义:当节点按升序或降序插入时,二叉搜索树可能会退化为链表(即每个节点只有一个子节点),这被称为单支树。
-
比较次数:
- 在这种情况下,树的高度为 h=N。
- 查找、插入和删除操作的平均比较次数为 O(N),而具体的比较次数可以表示为 N/2,这是因为在最坏情况下,平均需要比较的次数是从根节点到叶节点的路径长度的一半。
- 这意味着在最差情况下,操作的效率显著降低,因为每个操作可能需要遍历整个树。
1. 时间复杂度
1.1 查找(Search)
- 平均情况:O(log n)
- 在平均情况下,BST 的高度为 log(n),因此查找操作需要遍历树的高度。
- 最坏情况:O(n)
- 如果树退化为链表(例如,插入顺序为升序或降序),查找操作需要遍历所有 n 个节点。
1.2 插入(Insert)
- 平均情况:O(log n)
- 类似于查找,插入操作也需要遍历树的高度。
- 最坏情况:O(n)
- 在树退化为链表的情况下,插入操作需要遍历所有 n 个节点。
1.3 删除(Delete)
- 平均情况:O(log n)
- 删除操作首先需要查找要删除的节点,然后根据节点的子树情况进行调整。
- 最坏情况:O(n)
- 同样,在树退化为链表的情况下,删除操作可能需要遍历所有 n 个节点。
2. 空间复杂度
- 空间复杂度:O(n)
- BST 需要存储 n 个节点,每个节点通常包含一个键值和两个指针(指向左子树和右子树),因此总的空间复杂度为 O(n)。
3. 平衡性
BST 性能的关键在于其平衡性。为了保持 O(log n) 的性能,通常会使用平衡二叉搜索树(如 AVL 树或红黑树)来避免最坏情况。
- AVL 树:通过旋转操作保持树的平衡,确保高度为 O(log n)。
- 红黑树:通过颜色标记和旋转操作保持平衡,同样确保高度为 O(log n)。
4. 总结
- 查找、插入和删除操作的平均时间复杂度为 O(log n),而最坏情况下可能达到 O(n)。
- 空间复杂度为 O(n),用于存储节点。
- 平衡性是影响 BST 性能的重要因素,使用平衡二叉搜索树可以显著提高性能。
5. 性能优化
为了优化 BST 的性能,可以考虑以下策略:
- 使用自平衡树:如 AVL 树、红黑树等,确保树的高度保持在 O(log n)。
- 随机化树结构:如 Treap,通过随机化元素的优先级来维护平衡。
- 使用 B 树或 B+ 树:在需要频繁读写的数据库场景中,B 树和 B+ 树比 BST 更为高效。
6. 适用场景
- 需要频繁查找、插入和删除操作的场景。
- 数据量较大且对性能要求较高的应用。
- 需要动态变化的数据结构,如动态集合、字典等。
map和set
在C++中,map
和 set
是两个非常有用的标准模板库 (STL) 容器,它们都基于红黑树实现,提供了高效的插入、删除和查找操作。
map
- 定义:
map
是一个关联容器,它存储键值对(key-value pairs)。每个键都是唯一的。 - 用法:可以通过键来访问对应的值。
- 语法:
std::map<KeyType, ValueType> myMap;
- 常用操作:
- 插入元素:
myMap[key] = value;
或myMap.insert(std::make_pair(key, value));
- 查找元素:
myMap.find(key);
- 删除元素:
myMap.erase(key);
- 插入元素:
- 特点:
- 按照键的升序排列。
- 支持快速查找,平均时间复杂度为 O(log n)。
set
- 定义:
set
是一个关联容器,它存储唯一的元素,自动排序。 - 用法:通常用于需要去重和快速查找的场景。
- 语法:
std::set<ValueType> mySet;
- 常用操作:
- 插入元素:
mySet.insert(value);
- 查找元素:
mySet.find(value);
- 删除元素:
mySet.erase(value);
- 插入元素:
- 特点:
- 按照元素的升序排列。
- 不允许重复元素,插入重复元素时会被忽略。
- 支持快速查找,平均时间复杂度为 O(log n)。
示例代码
#include <iostream>
#include <map>
#include <set>
int main() {
// 使用 map
std::map<std::string, int> ageMap;
ageMap["Alice"] = 30;
ageMap["Bob"] = 25;
// 访问 map
for (const auto& pair : ageMap) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 使用 set
std::set<int> uniqueNumbers;
uniqueNumbers.insert(1);
uniqueNumbers.insert(2);
uniqueNumbers.insert(1); // 重复元素不会插入
// 访问 set
for (const auto& number : uniqueNumbers) {
std::cout << number << std::endl;
}
return 0;
}
总结
map
适合需要键值对存储的场景,而set
适合需要存储唯一元素的场景。- 两者都提供了高效的查找和操作,适合不同的应用需求。
关联式容器
关联式容器是 C++ 标准库中用于存储键值对(key-value pairs)的容器类型。它们允许通过键(key)快速查找、插入和删除相应的值(value)。以下是 C++ 中几种主要的关联式容器及其特点。
1. std::map
- 定义:
std::map
是一个有序的关联容器,存储键值对,每个键都是唯一的。 - 内部实现:通常使用红黑树实现。
- 查找时间复杂度:平均 O(log n)。
2. std::unordered_map
- 定义:
std::unordered_map
是一个无序的关联容器,存储键值对,每个键都是唯一的。 - 内部实现:使用哈希表实现。
- 查找时间复杂度:平均 O(1),最坏情况下为 O(n)。
3. std::multimap
- 定义:
std::multimap
是一个有序的关联容器,允许多个相同的键。 - 内部实现:通常使用红黑树实现。
- 查找时间复杂度:平均 O(log n)。
4. std::unordered_multimap
- 定义:
std::unordered_multimap
是一个无序的关联容器,允许多个相同的键。 - 内部实现:使用哈希表实现。
- 查找时间复杂度:平均 O(1),最坏情况下为 O(n)。
总结
std::map
和std::multimap
是有序的,支持按键排序。std::unordered_map
和std::unordered_multimap
是无序的,提供更快的查找效率。
STL中的部分容器,比如:vector、list、deque、 forward_list(C++11)等,这些容器统称为序列式容器,因为其底层为线性序列的数据结构,里面存储的是元素本身。
关联式容器也是用来存储数据的,与序列式容器不同的是,其里面存储的是结构的键值对,在数据检索时比序列式容器效率更高。
键值对
用来表示具有一一对应关系的一种结构,该结构中一般只包含两个成员变量key和value,key代表键值,value表示与key对应的信息。比如:现在要建立一个英汉互译的字典,那该字典中必然 有英文单词与其对应的中文含义,而且,英文单词与其中文含义是一一对应的关系,即通过该单词,在词典中就可以找到与其对应的中文含义。
键值对的定义:
结构体定义解释
template <class T1, class T2>
struct pair {
typedef T1 first_type; // 定义第一个类型的别名
typedef T2 second_type; // 定义第二个类型的别名
T1 first; // 第一个值
T2 second; // 第二个值
// 默认构造函数
pair() : first(T1()), second(T2()) {}
// 带参数的构造函数
pair(const T1& a, const T2& b) : first(a), second(b) {}
};
关键要素
模板参数:
T1 和 T2 是模板参数,允许 pair 存储任意类型的两个值。
类型别名:
使用 typedef 定义了 first_type 和 second_type,使得在使用时更具可读性。
构造函数:
默认构造函数初始化 first 和 second 为其类型的默认值。
带参数的构造函数允许在创建 pair 实例时直接初始化这两个值。
树形结构的关联式容器
STL总共实现了两种不同结构的管理式容器:树型结构与哈希结构。树型结构的关联式容器主要有四种:map、set、multimap、multiset。这四种容器的共同点是:使用平衡搜索树(即红黑树)作为其底层结果,容器中的元素是一个有序的序列。
树形结构的关联式容器是 C++ 标准库中用于存储键值对的容器,通常使用树结构来实现。这些容器支持快速的查找、插入和删除操作。下面是一些常见的树形结构关联式容器。
1. std::map
- 定义:
std::map
是一个有序的关联容器,存储键值对,每个键都是唯一的。 - 内部实现:通常使用红黑树(自平衡二叉搜索树)实现。
- 特性:
- 键值对按键的升序排列。
- 支持按键查找、插入和删除操作。
- 时间复杂度:查找、插入和删除的平均和最坏情况下均为 O(log n)。
2. std::multimap
- 定义:
std::multimap
是一个有序的关联容器,允许多个相同的键。 - 特性:
- 键值对按键的升序排列。
- 支持按键查找、插入和删除操作。
- 时间复杂度:查找、插入和删除的平均和最坏情况下均为 O(log n)。
3. std::set
- 定义:
std::set
是一个有序的集合,它存储唯一的元素。 - 内部实现:也通常使用红黑树实现。
- 特性:
- 元素按升序排列。
- 不允许重复元素。
- 支持查找、插入和删除操作,时间复杂度为 O(log n)。
4. std::unordered_map
和 std::unordered_set
- 定义:这些容器使用哈希表实现,提供更快的查找速度,但不保证元素的顺序。
- 特性:
- 查找、插入和删除的平均时间复杂度为 O(1)。
- 不保证元素的顺序。
总结
- 树形结构的关联式容器(如
std::map
和std::multimap
)提供了有序的键值对存储,适用于需要保持元素顺序的场景。 - 哈希表的关联式容器(如
std::unordered_map
和std::unordered_set
)提供快速的查找,但不保持顺序,适用于对性能要求较高的场景。
set
set的介绍
1. set是按照一定次序存储元素的容器
2. 在set中,元素的value也标识它(value就是key,类型为T),并且每个value必须是唯一的。
set中的元素不能在容器中修改(元素总是const),但是可以从容器中插入或删除它们。
3. 在内部,set中的元素总是按照其内部比较对象(类型比较)所指示的特定严格弱排序准则进行
排序。
4. set容器通过key访问单个元素的速度通常比unordered_set容器慢,但它们允许根据顺序对
子集进行直接迭代。
5. set在底层是用二叉搜索树(红黑树)实现的。
注意:
1. 与map/multimap不同,map/multimap中存储的是真正的键值对<key, value>,set中只放
value,但在底层实际存放的是由<value, value>构成的键值对。
2. set中插入元素时,只需要插入value即可,不需要构造键值对。
3. set中的元素不可以重复(因此可以使用set进行去重)。
4. 使用set的迭代器遍历set中的元素,可以得到有序序列
5. set中的元素默认按照小于来比较
6. set中查找某个元素,时间复杂度为: O(log n)
7. set中的元素不允许修改(为什么?)
在 C++ 中,std::set 是一个有序的集合,存储唯一的元素。由于其内部实现通常使用红黑树等自平衡二叉搜索树,set 中的元素不允许修改,主要有以下几个原因:
1. 唯一性保证
std::set 中的每个元素都是唯一的。为了维护这一特性,set 使用元素的比较函数(通常是 < 运算符)来确定元素的顺序和唯一性。如果允许修改元素的值,可能会导致以下问题:
元素顺序被破坏:如果修改了元素的值,可能会使得该元素在树中的位置不再正确,从而违反容器的有序性。
唯一性冲突:修改元素的值可能会导致两个元素具有相同的值,从而违反唯一性约束。
2. 内部结构稳定性
由于 set 使用树形数据结构(如红黑树)来存储元素,树的结构依赖于元素的顺序。如果允许元素被修改:
树结构不再有效:修改元素可能使得树的平衡性和顺序不再符合预期,导致查找、插入和删除操作变得不稳定或不正确。
重新定位元素:如果元素的值被修改,set 可能需要重新定位该元素,但如果该元素的引用仍在使用中,可能会导致未定义行为。
3. 设计决策
std::set 的设计理念是提供一个只读的接口,确保元素的不可变性,从而简化内部实现并提高性能。通过不允许修改元素,set 的接口变得更加简单和可靠。
4. 解决方案
如果需要在集合中存储可变的对象,可以考虑以下几种方案:
使用指针或智能指针:将可变对象的指针存储在 set 中,而不是将对象本身存储在 set 中。这允许修改对象的内容,但仍然保持对元素的引用。
使用 std::map:如果需要键值对的存储,同时允许对值进行修改,可以考虑使用 std::map,其中键是唯一的,值是可变的。
总结
std::set 中的元素不允许修改是为了保证元素的唯一性和集合的内部结构稳定性。这种设计使得 set 在处理元素时更加高效和可靠。如果需要存储可变对象,可以考虑其他容器或设计模式。
8. set中的底层使用二叉搜索树(红黑树)来实现。
set的使用
std::set 是 C++ 标准库中的一个关联式容器,它的模板参数列表主要包括两个参数:键类型和比较函数。以下是详细的模板参数列表及其说明。
std::set 的模板定义
template <class Key, class Compare = std::less<Key>, class Allocator = std::allocator<Key>>
class set;
模板参数说明
Key:
类型:存储在集合中的元素类型。
描述:set 中的每个元素都是唯一的,由该类型的对象表示。
Compare(可选):
类型:一个可调用对象(如函数指针、函数对象或 Lambda 表达式)用于比较元素。
默认值:std::less<Key>,这是一个标准库提供的比较函数,用于按升序排列元素。
描述:如果需要自定义排序顺序,可以提供一个自定义的比较函数。例如,使用 std::greater<Key> 可以实现降序排列。
Allocator(可选):
类型:分配器类型,默认是 std::allocator<Key>。
描述:用于管理内存分配的对象。可以自定义分配器来控制内存管理的行为。
map
map的介绍
1. map是关联容器,它按照特定的次序(按照key来比较)存储由键值key和值value组合而成的元
素。
2. 在map中,键值key通常用于排序和惟一地标识元素,而值value中存储与此键值key关联的
内容。键值key和值value的类型可能不同,并且在map的内部,key与value通过成员类型
value_type绑定在一起,为其取别名称为pair:
typedef pair<const key, T> value_type;
3. 在内部,map中的元素总是按照键值key进行比较排序的。
4. map中通过键值访问单个元素的速度通常比unordered_map容器慢,但map允许根据顺序
对元素进行直接迭代(即对map中的元素进行迭代时,可以得到一个有序的序列)。
5. map支持下标访问符,即在[]中放入key,就可以找到与key对应的value。
6. map通常被实现为二叉搜索树(更准确的说:平衡二叉搜索树(红黑树))。
map的使用
std::map 是 C++ 标准库中的一个关联式容器,用于存储键值对(key-value pairs)。它的模板参数允许用户自定义存储的元素类型、排序方式和内存管理方式。以下是 std::map 的模板参数及其详细说明。
std::map 的模板定义
template <class Key, class T, class Compare = std::less<Key>, class Allocator = std::allocator<std::pair<const Key, T>>>
class map;
模板参数说明
Key:
类型:存储在映射中的键的类型。
描述:每个键都是唯一的,并用于检索对应的值。
T:
类型:存储在映射中的值的类型。
描述:与每个键关联的值,可以是任意类型。
Compare(可选):
类型:一个可调用对象(如函数指针、函数对象或 Lambda 表达式),用于比较键。
默认值:std::less<Key>,这是一个标准库提供的比较函数,用于按升序排列键。
描述:如果需要自定义键的排序顺序,可以提供一个自定义的比较函数。例如,使用 std::greater<Key> 可以实现降序排列。
Allocator(可选):
类型:分配器类型,默认是 std::allocator<std::pair<const Key, T>>。
描述:用于管理内存分配的对象。可以自定义分配器来控制内存管理的行为。
注意:在元素访问时,有一个与operator[]类似的操作at()(该函数不常用)函数,都是通过 key找到与key对应的value然后返回其引用,不同的是:当key不存在时,operator[]用默认 value与key构造键值对然后插入,返回该默认value,at()函数直接抛异常。
底层结构
前面对map/multimap/set/multiset,在其文档介绍中发现,这几个容器有个共同点是:其底层都是按照二叉搜索树来实现的,但是二叉搜索树有其自身的缺陷,假如往树中 插入的元素有序或者接近有序,二叉搜索树就会退化成单支树,时间复杂度会退化成O(N),因此 map、set等关联式容器的底层结构是对二叉树进行了平衡处理,即采用平衡树来实现。
AVL 树
二叉搜索树虽可以缩短查找的效率,但如果数据有序或接近有序二叉搜索树将退化为单支树,查 找元素相当于在顺序表中搜索元素,效率低下。因此,两位俄罗斯的数学家G.M.Adelson-Velskii 和E.M.Landis在1962年 发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右 子树高度之差的绝对值不超过1(需要对树中的结点进行调整),即可降低树的高度,从而减少平均搜索长度。
AVL 树是一种自平衡的二叉搜索树(Binary Search Tree, BST),由 Georgy Adelson-Velsky 和 Evgenii Landis 于 1962 年提出。AVL 树的特点是它在插入和删除操作后始终保持平衡,从而确保查找、插入和删除操作的时间复杂度为 O(log n)。
1. 特点
- 自平衡:AVL 树的每个节点都有一个平衡因子(Balance Factor),定义为其左子树的高度减去右子树的高度。平衡因子的值可以是 -1、0 或 1。
- 高度平衡:树的高度差不超过 1,确保树的高度始终保持在对数级别。
2. 平衡因子
-
平衡因子:对于节点
N
,平衡因子 BF(N) 定义为:BF(N)=height(N.left)−height(N.right)
-
平衡状态:
- 如果 BF(N) = 0,节点是平衡的。
- 如果 BF(N) = 1,节点的左子树比右子树高 1。
- 如果 BF(N) = -1,节点的右子树比左子树高 1。
- 如果 BF(N) > 1 或 BF(N) < -1,节点是不平衡的。
3. 旋转操作
当 AVL 树失去平衡时,需要通过旋转操作来恢复平衡。主要有四种旋转:
- 右旋(Right Rotation):
- 用于左子树高的节点(Left-Left 情况)。
- 左旋(Left Rotation):
- 用于右子树高的节点(Right-Right 情况)。
- 左右旋(Left-Right Rotation):
- 先对左子树进行左旋,再对根节点进行右旋(Left-Right 情况)。
- 右左旋(Right-Left Rotation):
- 先对右子树进行右旋,再对根节点进行左旋(Right-Left 情况)。
4. 操作
插入
- 插入新节点,按照 BST 的规则插入。
- 更新每个节点的高度。
- 检查并更新平衡因子。
- 如果某个节点不平衡,执行相应的旋转操作以恢复平衡。
删除
- 按照 BST 规则删除节点。
- 更新每个节点的高度。
- 检查并更新平衡因子。
- 如果某个节点不平衡,执行相应的旋转操作以恢复平衡。
#pragma once
#include <utility>
#include <iostream>
#include <cmath> // 用于 std::abs
using namespace std;
// AVL树节点的结构体
template <class K, class V>
struct AVLTreeNode
{
AVLTreeNode<K, V>* _left; // 左子节点指针
AVLTreeNode<K, V>* _right; // 右子节点指针
AVLTreeNode<K, V>* _parent; // 父节点指针
int _bf; // 平衡因子
pair<K, V> _kv; // 存储键值对
// 构造函数,初始化节点
AVLTreeNode(const pair<K, V>& kv) : _left(nullptr), _right(nullptr), _parent(nullptr), _kv(kv), _bf(0) {}
};
// AVL树类
template <class K, class V>
class AVLTree
{
typedef AVLTreeNode<K, V> Node; // 定义节点类型
public:
// 插入键值对
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv); // 如果是空树,创建根节点
return true;
}
Node* parent = nullptr;
Node* cur = _root;
// 查找插入位置
while (cur)
{
parent = cur;
if (cur->_kv.first > kv.first)
{
cur = cur->_left; // 向左子树移动
}
else if (cur->_kv.first < kv.first)
{
cur = cur->_right; // 向右子树移动
}
else
{
return false; // 遇到重复键
}
}
// 创建新节点并插入
cur = new Node(kv);
if (parent->_kv.first > kv.first)
{
parent->_left = cur; // 插入左子树
}
else
{
parent->_right = cur; // 插入右子树
}
cur->_parent = parent; // 设置父节点
// 插入后进行树的平衡调整
BalanceAfterInsert(cur);
return true;
}
// 中序遍历
void InOrder()
{
_InOrder(_root);
}
// 检查树是否平衡
bool IsBalance()
{
return _IsBalance(_root);
}
// 删除键值对
bool Delete(const K& key)
{
Node* nodeToDelete = FindNode(_root, key);
if (!nodeToDelete)
{
return false; // 如果节点不存在,返回 false
}
DeleteNode(nodeToDelete);
return true;
}
private:
Node* _root = nullptr; // 树的根节点
// 中序遍历的递归函数
void _InOrder(Node* root)
{
if (root == nullptr) return; // 基本情况
_InOrder(root->_left); // 遍历左子树
cout << root->_kv.first << ":" << root->_kv.second << endl; // 输出当前节点
_InOrder(root->_right); // 遍历右子树
}
// 检查树是否平衡的递归函数
bool _IsBalance(Node* root)
{
if (root == nullptr) return true; // 空树是平衡的
return abs(GetHeight(root->_left) - GetHeight(root->_right)) <= 1 && // 检查平衡因子
_IsBalance(root->_left) && _IsBalance(root->_right); // 递归检查左右子树
}
// 获取树的高度
int GetHeight(Node* root)
{
if (root == nullptr) return 0; // 空树高度为0
return 1 + max(GetHeight(root->_left), GetHeight(root->_right)); // 计算高度
}
// 插入后调整树的平衡
void BalanceAfterInsert(Node* node)
{
while (node)
{
UpdateBalanceFactor(node); // 更新平衡因子
// 检查是否需要旋转以维持平衡
if (node->_bf == 2)
{
if (node->_left && node->_left->_bf == -1)
{
RotateLR(node); // 左右旋转
}
else
{
RotateR(node); // 单右旋转
}
break; // 旋转后停止调整
}
else if (node->_bf == -2)
{
if (node->_right && node->_right->_bf == 1)
{
RotateRL(node); // 右左旋转
}
else
{
RotateL(node); // 单左旋转
}
break; // 旋转后停止调整
}
node = node->_parent; // 向上移动到父节点
}
}
// 更新节点的平衡因子
void UpdateBalanceFactor(Node* node)
{
int leftHeight = GetHeight(node->_left); // 左子树高度
int rightHeight = GetHeight(node->_right); // 右子树高度
node->_bf = leftHeight - rightHeight; // 计算平衡因子
}
// 左旋操作
void RotateL(Node* parent)
{
Node* subR = parent->_right; // 右子节点
parent->_right = subR->_left; // 将右子树的左子树接到父节点的右子树
if (subR->_left)
{
subR->_left->_parent = parent; // 更新父节点
}
subR->_left = parent; // 将父节点设为右子节点的左子树
// 更新父节点指针
if (parent->_parent)
{
if (parent == parent->_parent->_left)
{
parent->_parent->_left = subR; // 父节点的左子树
}
else
{
parent->_parent->_right = subR; // 父节点的右子树
}
}
else
{
_root = subR; // 更新根节点
}
subR->_parent = parent->_parent; // 更新右子节点的父节点
parent->_parent = subR; // 更新父节点的父节点
UpdateBalanceFactor(parent); // 更新平衡因子
UpdateBalanceFactor(subR); // 更新平衡因子
}
// 右旋操作
void RotateR(Node* parent)
{
Node* subL = parent->_left; // 左子节点
parent->_left = subL->_right; // 将左子树的右子树接到父节点的左子树
if (subL->_right)
{
subL->_right->_parent = parent; // 更新父节点
}
subL->_right = parent; // 将父节点设为左子节点的右子树
// 更新父节点指针
if (parent->_parent)
{
if (parent == parent->_parent->_left)
{
parent->_parent->_left = subL; // 父节点的左子树
}
else
{
parent->_parent->_right = subL; // 父节点的右子树
}
}
else
{
_root = subL; // 更新根节点
}
subL->_parent = parent->_parent; // 更新左子节点的父节点
parent->_parent = subL; // 更新父节点的父节点
UpdateBalanceFactor(parent); // 更新平衡因子
UpdateBalanceFactor(subL); // 更新平衡因子
}
// 右左旋操作
void RotateRL(Node* parent)
{
RotateR(parent->_right); // 先进行右旋
RotateL(parent); // 然后进行左旋
}
// 左右旋操作
void RotateLR(Node* parent)
{
RotateL(parent->_left); // 先进行左旋
RotateR(parent); // 然后进行右旋
}
// 查找节点
Node* FindNode(Node* root, const K& key) {
while (root) {
if (key < root->_kv.first) {
root = root->_left;
}
else if (key > root->_kv.first) {
root = root->_right;
}
else {
return root; // 找到节点
}
}
return nullptr; // 未找到
}
// 删除节点函数
void DeleteNode(Node* node)
{
Node* parent = node->_parent;
// 处理节点的子节点情况
if (!node->_left && !node->_right)
{
// 情况 1:删除叶子节点
if (parent) {
if (parent->_left == node)
{
parent->_left = nullptr;
}
else
{
parent->_right = nullptr;
}
}
else
{
_root = nullptr; // 如果是根节点
}
}
else if (!node->_left || !node->_right)
{
// 情况 2:只有一个子节点
Node* child = node->_left ? node->_left : node->_right;
if (parent)
{
if (parent->_left == node)
{
parent->_left = child;
}
else
{
parent->_right = child;
}
}
else
{
_root = child; // 如果是根节点
}
child->_parent = parent; // 更新子节点的父节点
}
else
{
// 情况 3:有两个子节点
Node* successor = FindMin(node->_right);
node->_kv = successor->_kv; // 用后继节点的值替换当前节点
DeleteNode(successor); // 删除后继节点
return; // 结束
}
delete node; // 删除当前节点
// 从父节点开始向上调整树的平衡
BalanceAfterDelete(parent);
}
// 找到树中最小的节点
Node* FindMin(Node* node)
{
while (node->_left)
{
node = node->_left; // 一直向左子树查找
}
return node;
}
// 从给定节点开始向上调整树的平衡
void BalanceAfterDelete(Node* node)
{
while (node)
{
UpdateBalanceFactor(node); // 更新平衡因子
// 检查是否需要旋转以维持平衡
if (node->_bf == 2)
{
if (node->_left && node->_left->_bf == -1)
{
RotateLR(node); // 左右旋转
}
else
{
RotateR(node); // 单右旋转
}
}
else if (node->_bf == -2)
{
if (node->_right && node->_right->_bf == 1)
{
RotateRL(node); // 右左旋转
}
else
{
RotateL(node); // 单左旋转
}
}
node = node->_parent; // 向上移动到父节点
}
}
};
// 示例测试函数
void Test_AVLTree()
{
int a[] = { 16, 3, 7, 11, 9, 26, 18, 14, 15 };
AVLTree<int, int> t; // 创建 AVL 树
for (auto e : a)
{
t.Insert(make_pair(e, e)); // 插入测试数据
}
t.InOrder(); // 中序遍历输出
cout << (t.IsBalance() ? "树是平衡的" : "树不平衡") << endl; // 检查树的平衡性
t.Delete(3);
t.InOrder();
}
总结
- AVL 树 是一种自平衡的二叉搜索树,确保所有基本操作的时间复杂度为 O(log n)。
- 通过旋转操作,AVL 树能够在插入和删除节点后保持平衡。
- 它适用于需要频繁查找和更新的场景,效率较高。
AVL树的验证
AVL树是在二叉搜索树的基础上加入了平衡性的限制,因此要验证AVL树,可以分两步:
1. 验证其为二叉搜索树
如果中序遍历可得到一个有序的序列,就说明为二叉搜索树
2. 验证其为平衡树
每个节点子树高度差的绝对值不超过1(注意节点中如果没有平衡因子)
节点的平衡因子是否计算正确
AVL树的性能
AVL 树是一种自平衡的二叉搜索树(BST),其性能在多种操作上具有优势。
1. 时间复杂度
AVL 树在执行基本操作时的时间复杂度均为 O(log n),具体包括:
- 查找:O(log n)
- 插入:O(log n)
- 删除:O(log n)
原因
- 自平衡特性:每次插入或删除操作后,AVL 树会通过旋转操作保持树的平衡。由于树的高度始终保持在 O(log n),因此所有操作的时间复杂度也保持在 O(log n)。
2. 空间复杂度
AVL 树的空间复杂度是 O(n),其中 n 是树中节点的数量。这是因为 AVL 树需要存储每个节点的值、指向子节点的指针以及每个节点的高度信息。
3. 优势
- 平衡性:AVL 树始终保持较高的平衡性,这使得查找和修改操作在最坏情况下也能保持较快的速度。
- 适用于频繁查找的场景:由于 AVL 树的查找性能较好,适合用于需要频繁读取数据的应用场景。
4. 劣势
- 插入和删除的开销:虽然 AVL 树在查找操作上表现优秀,但插入和删除操作相对较复杂,需要进行旋转和重新平衡。这可能导致在某些情况下性能下降,尤其是在频繁进行插入和删除的场景中。
- 内存消耗:由于需要额外存储每个节点的高度信息,AVL 树的内存消耗相对于普通的二叉搜索树稍高。
5. 适用场景
- 需要高效查找的应用:如数据库索引、内存中的数据结构等。
- 经常需要保持数据有序的应用:例如实现集合、映射等数据结构。
- 动态数据集:在需要频繁插入和删除的情况下,尽管操作开销较大,但仍然保持较好的查找效率。
红黑树
红黑树是一种自平衡的二叉搜索树(Binary Search Tree, BST),它能够确保在最坏情况下也能实现 O(log n) 的查找、插入和删除操作。红黑树由 Rudolf Bayer 在 1972 年提出,广泛应用于现代计算机科学中,尤其是在实现关联式容器(如 C++ STL 的 std::map
和 std::set
)时。
1. 红黑树的性质
红黑树具有以下性质,以确保树的平衡性:
- 节点颜色:每个节点是红色或黑色。
- 根节点:根节点始终是黑色。
- 红色节点:红色节点的子节点必须是黑色(即没有两个红色节点相连)。
- 黑色节点:从任何节点到其每个叶子节点的路径上,必须包含相同数量的黑色节点(黑色高度)。
- 叶子节点:所有叶子节点(空节点)都被视为黑色。
2. 性能
红黑树的性能与 AVL 树相似,主要包括:
- 查找:O(log n)
- 插入:O(log n)
- 删除:O(log n)
3. 操作
插入
- 普通插入:如同在普通的二叉搜索树中插入节点。
- 调整颜色和结构:插入后,可能会破坏红黑树的性质。需要进行颜色调整和树的旋转(左旋或右旋)以恢复红黑树的性质。
删除
- 普通删除:如同在普通的二叉搜索树中删除节点。
- 调整颜色和结构:删除后,同样可能会破坏红黑树的性质,需要进行调整以保持红黑树的平衡。
4. 旋转操作
红黑树使用旋转操作来维持平衡:
- 左旋(Left Rotation):将一个节点的右子树移动到其位置,同时将该节点自身变为其左子树。
- 右旋(Right Rotation):将一个节点的左子树移动到其位置,同时将该节点自身变为其右子树。
红黑树,是一种二叉搜索树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或 Black。 通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍,因而是接近平衡的。
红黑树的性质
1. 每个结点不是红色就是黑色
2. 根节点是黑色的
3. 如果一个节点是红色的,则它的两个孩子结点是黑色的
4. 对于每个结点,从该结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点
5. 每个叶子结点都是黑色的(此处的叶子结点指的是空结点)
红黑树通过其特定的性质确保了树的平衡性,从而保证了最长路径中的节点个数不会超过最短路径节点个数的两倍。以下是详细的解释:
红黑树的性质
- 每个节点是红色或黑色。
- 根节点是黑色。
- 红色节点的子节点必须是黑色(没有两个红色节点相连)。
- 从任何节点到其每个叶子节点的路径上,必须包含相同数量的黑色节点。
关键推导
1. 黑色高度
- 定义:黑色高度(Black Height)是指从某个节点到其每个叶子节点的路径上所经过的黑色节点的数量(不包括当前节点)。
- 红黑树的性质保证了从根到每个叶子节点的黑色节点数量相同,记为
h_b
。
2. 最短路径与最长路径
- 最短路径:从根节点到最接近叶子的路径(全是黑色节点),路径长度为
h_b
。 - 最长路径:从根节点到最远叶子的路径,这条路径可能包含红色节点。根据红黑树的性质,红色节点不能连续出现,因此在每个黑色节点后最多可以有一个红色节点。
3. 节点数量关系
- 在最长路径中,假设有
h_b
个黑色节点,那么:- 每个黑色节点最多可以有一个红色节点,因此在最长路径中,红色节点的数量最多为
h_b - 1
(因为最后一个黑色节点是叶子,不计入)。
- 每个黑色节点最多可以有一个红色节点,因此在最长路径中,红色节点的数量最多为
- 因此,最长路径的节点数
h_l
可以表示为:h_1 = h_b + (h_b - 1) = 2h_b -1
- 这意味着:
h_1 < 2h_b
结论
- 由于红黑树的性质,我们可以得出最长路径节点的数量不会超过最短路径节点数量的两倍。即对于红黑树,最长路径的长度是最短路径长度的两倍,这保持了树的平衡性,确保了 O(log n) 的操作时间复杂度。
#include <iostream>
using namespace std;
// 红黑树节点结构
enum Color { RED, BLACK };
struct Node {
int data;
Color color;
Node *left, *right, *parent;
Node(int data) : data(data), color(RED), left(nullptr), right(nullptr), parent(nullptr) {}
};
// 红黑树类
class RedBlackTree {
private:
Node *root;
protected:
void leftRotate(Node *&x) {
Node *y = x->right;
x->right = y->left;
if (y->left != nullptr)
y->left->parent = x;
y->parent = x->parent;
if (x->parent == nullptr)
root = y;
else if (x == x->parent->left)
x->parent->left = y;
else
x->parent->right = y;
y->left = x;
x->parent = y;
}
void rightRotate(Node *&x) {
Node *y = x->left;
x->left = y->right;
if (y->right != nullptr)
y->right->parent = x;
y->parent = x->parent;
if (x->parent == nullptr)
root = y;
else if (x == x->parent->left)
x->parent->left = y;
else
x->parent->right = y;
y->right = x;
x->parent = y;
}
void fixViolation(Node *&z) {
Node *y = nullptr;
while (z->parent != nullptr && z->parent->color == RED) {
if (z->parent == z->parent->parent->left) {
y = z->parent->parent->right;
if (y != nullptr && y->color == RED) {
z->parent->color = BLACK;
y->color = BLACK;
z->parent->parent->color = RED;
z = z->parent->parent;
} else {
if (z == z->parent->right) {
leftRotate(z->parent);
z = z->parent;
}
rightRotate(z->parent->parent);
swap(z->parent->color, z->parent->parent->color);
z = z->parent;
}
} else {
y = z->parent->parent->left;
if (y != nullptr && y->color == RED) {
z->parent->color = BLACK;
y->color = BLACK;
z->parent->parent->color = RED;
z = z->parent->parent;
} else {
if (z == z->parent->left) {
rightRotate(z->parent);
z = z->parent;
}
leftRotate(z->parent->parent);
swap(z->parent->color, z->parent->parent->color);
z = z->parent;
}
}
}
root->color = BLACK;
}
public:
RedBlackTree() : root(nullptr) {}
void insert(int data) {
Node *z = new Node(data);
Node *y = nullptr;
Node *x = root;
while (x != nullptr) {
y = x;
if (z->data < x->data)
x = x->left;
else
x = x->right;
}
z->parent = y;
if (y == nullptr)
root = z;
else if (z->data < y->data)
y->left = z;
else
y->right = z;
fixViolation(z);
}
void inorderHelper(Node *root) {
if (root == nullptr) return;
inorderHelper(root->left);
cout << root->data << " ";
inorderHelper(root->right);
}
void inorder() {
inorderHelper(root);
}
};
int main() {
RedBlackTree rbt;
rbt.insert(10);
rbt.insert(20);
rbt.insert(30);
rbt.insert(15);
cout << "Inorder Traversal: ";
rbt.inorder();
cout << endl;
return 0;
}