本文主要面向的是曾经学过、了解过C++的同学,旨在帮助这些同学唤醒C++的记忆,提升下自身的技术储备。如果之前完全没接触过C++,也可以整体了解下这门语言。
面向受众
本文主要面向的是曾经学过、了解过C++的同学,旨在帮助这些同学唤醒C++的记忆,提升下自身的技术储备。如果之前完全没接触过C++,也可以整体了解下这门语言。
C++是一种通用编程语言,它被广泛用于软件开发。C++以其强大的功能、高效的性能和灵活性而著称。以下是一些关键特点:
- 面向对象:C++支持面向对象编程(OOP)的四大特性:封装、继承、多态和抽象。通过类和对象,程序员能够创建模块化的代码,更容易地进行维护和扩展。
- 泛型编程:C++支持模板编程,允许编写与数据类型无关的代码。模板是实现泛型编程的关键工具,它们提高了代码的复用性。
- 直接内存管理:C++提供了对内存的直接操作能力,允许程序员手动管理内存分配和释放,这是C++的一个强大特性,也是需要谨慎使用的地方,因为不当的内存管理可能会导致资源泄露和其他问题。
- 性能:C++编写的程序通常有很高的执行效率,这是因为C++提供了与底层硬件直接对话的能力。这使得C++成为开发要求性能的系统软件(如操作系统、游戏引擎)的理想选择。
- C语言兼容:大部分C语言程序可以在C++编译器上编译并运行。这一特性简化了从C到C++的过渡。
- 多编程范式支持:除了面向对象和泛型编程外,C++还支持过程式编程和函数式编程等范式,使其成为一个多样化的工具,能适应不同的编程需求。
C++语言的复杂劝退了很多人,诸如指针、虚函数、泛型等语言特性让C++变得特别复杂。事实也确实如此,不过C++的作者说过:“轻松地使用这种语言。不要觉得必须使用所有的特性,不要在第一次学习时就试图使用所有特性。”
本文主要内容是介绍现代C++(C++11及之后的版本)中的语法和特性,不会深入语法细节,每小节最后可能会列出一些相关的拓展知识点,感兴趣的同学可以自行了解。
语法基础
▐ 类型
C++是静态编译语言,所有变量在声明时都要指定具体的变量类型,或者能让编译器推导出具体的变量类型(比如使用 auto、decltype 关键字的场景),类型检查不通过将导致编译期出错。
- 基础类型
C++的基础类型可以按照其所能表示的数据类型来分类。以下表格列出了C++的基础类型及其常见的大小和范围(请注意,实际的大小和范围可能根据平台和编译器的不同而有所变化):
类别 |
类型 | 大小(位) | 备注 |
整形类型 | short | 至少 16 | |
unsigned short | 至少 16 | ||
int | 至少 16 | 通常是 32 | |
unsigned int | 至少 16 | ||
long | 至少 32 | ||
unsigned long | 至少 32 | ||
long long | 至少 64 | C++11 新增 | |
unsigned long long | 至少 64 | C++11 新增 | |
定宽整数 (从 <cstdint> 导入) | 8/16/32/64 | int8_t, int16_t, int32_t, int64_t 等 | |
无符号定宽整数 (从 <cstdint> 导入) | 8/16/32/64 | uint8_t, uint16_t, uint32_t, uint64_t 等 | |
浮点数类型 | float | 32 | 单精度浮点数 |
double | 64 | 双精度浮点数 | |
long double | 实现依赖 | 扩展精度浮点数,精度和大小由具体实现定义 | |
字符类型 | char | 至少 8 | 可表示字符或小整数,有符号性由实现定义 |
signed char | 8 | 明确的有符号字符类型 | |
unsigned char | 8 | 无符号字符类型 | |
char16_t | 16 | C++11 新增,用于 UTF-16 字符 | |
char32_t | 32 | C++11 新增,用于 UTF-32 字符 | |
wchar_t | 实现依赖 | 用于宽字符集 | |
布尔类型 | bool | 实现依赖 | 表示布尔值 true 或 false |
特殊类型 | void | N/A | 表示无类型,用于函数返回值 |
nullptr_t | 指针宽度(32/64) | C++11 新增,表示空指针 nullptr 的类型 | |
自动类型 | auto | N/A | C++11 新增,允许编译器自动推导变量类型 |
指针和引用类型 | 指针类型 | 指针宽度(32/64) | 例如 int* 表示整数指针 |
引用类型 | 一般和指针类型相同 | 例如 int& 表示整数引用 |
语法示例:
int a; // 声明未初始化,使用前建议手动初始化 char b = 'a'; float c = 1.0f; // C++中默认小数是double类型,加上f可以指定为float double d = 2.0; auto e = 20; // 编译器自动推导auto为 int 类型
基础类型隐式转换
编译器自动进行的类型转换,不需要程序员进行任何操作。这些转换通常在类型兼容的情况下发生,比如从小的整数类型转换到大的整数类型。下面是经常遇到的隐式类型转换:
- 安全的隐式转换:
- 整型提升:小的整型(如 char、short)会自动转换成较大的整型(如 int)。
- 算术转换:例如,当 int 和 double 混合运算时,int 会转换为 double。
- 存在隐患的隐式转换:
- 窄化转换:大的整数类型转换到小的整数类型,或者浮点数转换到整数,可能会造成数据丢失或截断。
- 指针转换:例如,将 void* 转换为具体类型的指针时,如果转换不正确,会导致未定义行为。
- 结构体(struct)
结构体是不同类型数据的集合,允许将数据组织成有意义的组合。
语法示例:
// 结构体定义struct Person { std::string name; int age; } // 结构体初始化, Person person = {"Jim", 20}; Person person2; // 创建另一个实例 person2 = person;// 将person中的值复制到person2中,默认是浅拷贝,在有指针的情况下有潜在风险枚举(enum)
枚举是一种用户定义的类型,它可以赋予一组整数值具有更易读的别名。语法示例:
enum Color { RED, GREEN, BLUE }; // 使用 Color myColor = RED;
C++11引入了新的枚举类型 作用域枚举。
语法示例:
enum class Color { RED, GREEN, BLUE }; Color myColor = Color::RED; // 使用作用域解析运算符(::)访问枚举值
作用域枚举解决了传统枚举可能导致命名冲突的问题,并提供了更强的类型检查。
- 联合体(union)
联合体允许在相同的内存位置存储不同类型的数据,但一次只能使用其一。
语法示例:
// 联合体的定义 union Data { int intValue; float floatValue; char charValue; } // 联合体一次只能保存一种类型的数据,每次赋值都会覆盖内存中之前的值 // 因此联合体一般是配合结构体来使用,下面是一个示例 // 定义数据类型的枚举 enum DataType { INT, FLOAT, CHAR }; // 定义一个结构体,它包含一个联合体和一个枚举标签 struct SafeUnion { // 标记当前联合体中存储的数据类型 DataType type; // 定义联合体 union { int intValue; float floatValue; char charValue; } data; }; // 赋值操作 SafeUnion su; su.type = FLOAT; su.data.floatValue = 1.0f; // 使用时,通过type判断类型然后访问联合体对应的成员变量 switch(su.type) { case FLOAT: cout << su.data.floatValue << endl; break; }
- 类(class)
类是C++的核心特性,是面向对象的基础,允许将数据和操作这些数据的函数封装为一个对象。这里先只介绍定义。
语法示例:
class Person { public: void doWork(); // 方法,类对外提供的一系列操作实例的函数 private: std::string name; // 成员变量,封装到类中的属性,保存内部状态信息 int age; };
- 列表初始化
现代C++提供了一种新的统一的变量初始化方式 - 列表初始化,推荐优先使用这种初始化方式,它能提供更加直观和统一的数据初始化方式。
列表初始化使用 {} 来初始化数据对象,包括基础类型、数组、结构体、类和容器等复杂的数据类型。语法示例:
// 基础类型 int a{0}; double b{3.14}; // 结构体 struct MyStruct { int x; double y; }; MyStruct s{1, 2.0}; // 类 class MyClass { public: MyClass(int a, double b) : a_(a), b_(b) {} private: int a_; double b_; }; MyClass obj{5, 3.14}; // MyClass 必须有一个匹配这个参数列表的构造函数 // 数组 int arr[3]{1, 2, 3}; // 上面介绍的都是现代C++推荐写法,省略 = // 下面的2种写法绝大多数情况下是等价的 float arr[2]{1, 2}; // 写法1 float arr[2] = {1, 2}; // 写法2 // 编译器对这两种写法的处理是一致的,方法2并不会产生临时变量和拷贝赋值,包括类的声明
现代C++推荐优先使用列表初始化来初始化变量,因为这种方式不允许进行窄化转换这能避免一些问题的发生,示例:
int a = 7.7; // 编译能通过,但是有warning int b = {1.0}; // 编译器拒绝通过,因为浮点到整形的转换会丢失精度
列表初始化支持参数列表小于数据对象的个数,这种情况下会默认进行其他变量的零初始化。
拓展:
- 零初始化
▐ 数组
C++的数组是一个固定大小的序列容器,它可以存储特定类型的元素的集合。数组中的元素在内存中连续存储,这允许快速的随机访问,即可以直接通过索引访问任何元素,而无需遍历数组。
- 数组的声明
数组的声明形式如下:
Typename arrayName[Size]; // 基本类型 int arr[10]; char charArr[30]; // 复杂类型 struct Point { int x; int y; } Point points[10];
这里 Typename 是数组中元素的数据类型,arrayName 是数组的变量名,Size 是数组的元素个数,在这种声明形式下必须是整形的常量。
这里介绍的方式是数组的静态声明方式,即数组的元素个数在编译期间就能确定,数组占用的内存分配在栈内存中,实际开发中更多的情况可能是更具运行时的值确定数组的大小,这时需要动态的方式声明数组,后面会介绍。
- 数组的初始化
数组定义时如果未进行初始化,那么数组中的元素的值都是内存中残留的数据,而这些数据通常没有意义,直接使用会导致不可预知的问题。因此声明数组后需要对数组进行必要的初始化。
数组支持列表初始化语法:
int arr[] = {1, 2, 3, 4, 5}; // 数组大小为5,编译器自动确定 int arr[10] = {1, 2, 3}; // 数组前三项确定为1,2,3,其余被初始化为0 int arr[10] = {0}; // 整个数组全部为0int arr[] = {1, 2, 3, 4, 5}; // 数组大小为5,编译器自动确定int arr[10] = {1, 2, 3}; // 数组前三项确定为1,2,3,其余被初始化为0int arr[10] = {0}; // 整个数组全部为0
- 数组的使用
数组中的元素可以通过索引来访问和修改,索引从0开始,第一元素索引是0,最后一个索引是Size-1。
int arr[] = {1, 2, 3, 4, 5}; // 数组大小为5,编译器自动确定 int arr[10] = {1, 2, 3}; // 数组前三项确定为1,2,3,其余被初始化为0 int arr[10] = {0}; // 整个数组全部为0
- 数组在声明后(无论静态声明还是动态声明),数组的大小即固定,不可更改;
- 数组不提供任何内置的方法来获取其大小,通常需要额外保存数组的大小,或者使用特殊标记结束元素(C风格的字符串使用'\0'表示数组结束);
- 数组不提供边界检查,越界访问的代码是可以通过编译的(静态数组编译器会给出警告),可能导致很多潜在问题。
下面是越界访问的案例:
int arr[10] = {0}; int a = arr[10]; // 最大的有效索引是9,这里出现越界,但编译器能顺利编译通过(有警告) // a中的值是不确定的,没有实际意义的,这里是读取,危害可能有限 arr[10] = 99; // 可怕的是该语句也能通过编译,但这里进行了更加危险的操作, // 越界访问了一块内存并修改了其内容,这很可能导致程序崩溃
- 多维数组
二维数组
上面提到的数组存储的是一维的,即一系列同类型数据,但有时需要存储一个表格数据,需要区分行列,这时可以使用二维数组来存储。
Typename arrayName[Rows][Columns]; // Rows是行数, Columns是列数, 必须常量 // 实际示例 int arr[10][10]; // 定义了一个10*10的二维数组
下面是二维数组的初始化:
// 完全初始化 int matrix[2][3] = { {1, 2, 3}, {4, 5, 6} }; // 部分初始化 int matrix[2][3] = { {1, 2}, // 第一行的最后一个元素将被初始化为 0 {4} // 第二行的第二个和第三个元素将被初始化为 0 }; // 单行初始化 int matrix[2][3] = {1, 2, 3}; // 只初始化第一行,其他行将默认初始化为0 // 自动推断,和一维数组一样,编译器会根据数组推断二维数组第一维的大小 int matrix[][3] = { {1, 2, 3}, {4, 5, 6} };
多维数组
多维数据是和二维数组类似,在基础上再增加一维
Typename arrayName[Depth][Rows][Columns];
当然可以推广这个概念,定义出四维、五维等等数组形式,这里不展开。
数组的替代
数组本身是一种常见的C++数据类型,使用范围很广,但是本身也存在局限性。因此为了提升开发效率,C++标准库中提供了更加灵活的数据容器供开发者使用:
- std::vector: 可变大小的数组。提供对元素的快速随机访问,并能高效地在尾部添加和删除元素。
- std::list 双向链表。支持在任何位置快速插入和删除元素,但不支持快速随机访问。
- std::deque: 双端队列。类似于std::vector,但提供在头部和尾部快速添加和删除元素的能力。
- std::array (C++11): 固定大小的数组。提供对元素的快速随机访问,并且其大小在编译时确定。
- std::forward_list (C++11): 单向链表。提供在任何位置快速插入和删除元素,但不支持快速随机访问。std::stack: 栈容器适配器。提供后进先出(LIFO)的数据结构。std::queue: 队列容器适配器。提供先进先出(FIFO)的数据结构。
- std::priority_queue: 优先队列容器适配器。元素按优先级出队,通常使用堆数据结构实现。
- std::set: 一个包含排序唯一元素的集合。基于红黑树实现。
- std::multiset: 一个包含排序元素的集合,元素可以重复。基于红黑树实现。
- std::unordered_set (C++11): 一个包含唯一元素的集合,但不排序。基于散列函数实现。
- std::unordered_multiset (C++11): 一个包含元素的集合,元素可以重复,但不排序。基于散列函数实现。
▐ 指针
在C++中,指针是一种基础数据类型,它存储了内存地址的值。通过指针,可以直接读取或修改相应内存地址处的数据。指针是C/C++强大功能的一个关键组成部分,允许直接操作内存,这在底层编程和系统编程中非常有用,但这一切能力的代价就是指针操作的高风险。
- 理解指针
下面是简单的整形变量和整形指针变量在内存中的示意图:
可以看出:
- a是一个整形,占用4个字节(一般int类型占用4字节),0xffffffffffffecdc是其首地址,内存中的值是2568,即代码中的赋值(具体的存储细节可以搜索 大端序、小端序)
- p是一个整形指针,占用8个字节(64位系统),0xffffffffffffece0是其首地址,内存中的值是a变量内存的首地址,即0xffffffffffffecdc。
通过示意图,可以知道指针本身是一种变量类型,和int、bool这些类型没有本质的区别,只不过其他类型的变量中存储的是数据,而指针类型变量中存储的内存地址。一旦理解了这个概念,那么指针的指针这一概念也不难理解,它本身是一个指针类型,其中存储的值是另一个指针的地址。
- 指针的定义
指针的定义语法:
Typename * ptrName; // 指针定义风格,下面的声明都正确 int *p; // C风格,旨在强调 (*p)是一个整形值 int* p; // 经典C++风格,只在强调 p是一个整形指针类型(int*) // 集团推荐的风格,指针、引用都是居中,两边留空格 int * p; // 指针 int & a = xx; // 左值引用 int && a = xx; // 右值引用
不论指针的类型是什么,指针本身的内存占用是相同的,64位系统占用8个字节。指针类型存储的是地址编号,本质上是整形,可以进行计算,但对地址的乘除法是没有意义的,加减法是有意义的,表示地址的偏移。
对指针进行 +1操作,指针将会偏移其指向的类型所占用的字节数(编译器根据指针的类型确定偏移的字节数),下面有个实际例子:
int a = 123; // 假设 a 地址为 0xfffff100 int * p = &a; // 此时 p 中存储的值为 0xfffff100 p = p + 1; // 此时 p 中存储的值为 0xfffff104 (0xfffff100偏移4个字节,即int变量占用的大小)
- 指针初始化和访问
指针的赋值和访问语法如下:
int a = 5; int * p = &a; // & 取地址运算符 // * 用在指针这里是解引用运算符,可以获取指针指向的地址的值 cout << *p << endl; // 输出 5 int b = 10; p = &b; // 指针变量可以修改其指向地址 cout << *p << endl; // 输出 10
- 常量指针 vs 指针常量
常量指针
常量指针指向一个常量值,不管指向的变量本身是否声明为常量都不能通过指针来修改指向的内容,但指针本身可以重新赋值指向新的地址。
int value = 5; const int * p = &value; // p是一个常量指针 int const * q = &value; // 和上面的声明等价 *p = 10; // 非法,*p是常量不能修改 int a = 6; p = &a; // 合法,p本身不是常量,可以重新赋值
常量指针在函数传参时非常有用,它可以限制函数内部通过指针非法地修改原始内容。
指针常量
指针常量表示指针本身是常量,必须在声明时初始化,之后不能指向其他地址,但可以通过指针修改指向的内容。
int value = 5; int * const p = &value; // p是常量 *p = 6; // 合法 int a = 7; p = &a; // 非法
要记住这两种声明的区别有个简单的方法:看 const 修饰是什么:
const int * p :const修饰 *p,即 *p 是常量
int * const p :const修饰 p,即 p 是常量
C++从遗忘到入门(中):https://developer.aliyun.com/article/1480760