C++ 第五课
课程目标
- 理解头文件的作用、原理以及如何正确使用。
- 掌握 C++ 中赋值操作的多种形式和类型转换。
- 深入理解并熟练运用各类运算符。
- 掌握
break
和continue
语句在控制流中的作用。 - 理解宏定义的原理和使用场景,并了解其局限性。
- 掌握指针的算术运算,理解其在内存操作中的意义。
0. 头文件的原理与使用
头文件是什么:
- 头文件是以
.h
或.hpp
(推荐 C++) 结尾的文本文件。 - 主要包含函数声明、类声明、全局变量声明、宏定义、枚举和结构体声明等。
- 允许在多个源文件中共享声明,实现代码复用和模块化编程。
- 头文件是以
为什么要用头文件:
- 避免重复代码: 将函数和变量的声明放在头文件中,多个源文件只需包含该头文件即可使用,无需重复编写声明。
- 提高代码可读性和可维护性: 将接口 (声明) 和实现分离,使得代码结构更清晰,修改接口时只需修改头文件。
- 支持模块化编程: 将程序划分为多个模块,每个模块有自己的头文件,便于组织和管理大型项目。
- 支持编译分离: 允许将一个项目拆分成多个独立的编译单元,然后链接在一起,加快编译速度。
如何使用头文件:
使用
#include
预处理指令包含头文件。包含系统头文件: 使用尖括号
< >
,例如#include <iostream>
。编译器会在系统头文件目录下搜索。包含自定义头文件: 使用双引号
""
,例如#include "myheader.h"
。编译器会首先在当前源文件所在目录下搜索,如果找不到,则在系统头文件目录下搜索。头文件内容:
- 声明而非定义: 通常在头文件中放置函数和变量的声明,而不是它们的定义。全局变量的定义通常放在一个
.cpp
文件中。 - 类定义: 类定义通常放在头文件中。
- 内联函数: 内联函数的定义可以放在头文件中。
- 宏定义和常量: 常量可以使用
const
或constexpr
定义,宏定义也可以放在头文件中。
- 声明而非定义: 通常在头文件中放置函数和变量的声明,而不是它们的定义。全局变量的定义通常放在一个
避免重复包含: 使用头文件保护符 (include guards) 来防止头文件被多次包含,导致编译错误。
cpp#ifndef MYHEADER_H #define MYHEADER_H // 头文件内容 #endif // MYHEADER_H
组织自定义头文件: 建议将相关的声明放在同一个头文件中,并根据模块或功能进行组织。
现在流行用下面的方式:
#pragma once
1. 赋值总结
单行多个定义和连续赋值:
cppint a = 0, *pa = &a, *pa2 = pa, b = a, c = 2; // 单行定义多个变量,并进行初始化 int d; d = a = b = c = 9; // 连续赋值,从右向左进行,要求赋值号左边的变量已经声明 // 等价于 c = 9; b = c; a = b; d = a;
赋值运算符的返回值:
- 赋值运算符会返回左操作数的引用,这使得连续赋值成为可能。
类型转换:
隐式类型转换: 当将一个低精度类型的值赋给高精度类型的变量时,编译器会自动进行隐式类型转换。
cppint i = 10; double d = i; // 隐式类型转换,int 转换为 double
显式类型转换 (强制类型转换):
- C 风格类型转换:
(type)expression
,例如(double)i
。- 简洁但安全性较差,可能在不合适的情况下进行转换。
- C++ 风格类型转换: 更安全、更明确,有助于减少错误:
static_cast<type>(expression)
: 用于良性且可预测的类型转换,例如基本类型之间的转换、父类指针到子类指针的转换(非安全)。dynamic_cast<type*>(expression)
: 用于安全的向下转型(父类指针或引用转换为子类指针或引用),运行时检查类型安全,需要多态支持。const_cast<type*>(expression)
: 用于移除或添加 const 属性。reinterpret_cast<type*>(expression)
: 最强大的类型转换,允许将任意类型的指针转换为任意其他类型的指针,极不安全,应谨慎使用。
cppint i = 10; double d = static_cast<double>(i); // C++风格类型转换,将 int 转换为 double
- C 风格类型转换:
左值 (lvalue) 和右值 (rvalue):
- 左值: 指表达式结束后依然存在的持久对象,可以取地址,可以放在赋值运算符的左边。例如,变量名。
- 右值: 指表达式结束后就不再存在的临时对象或字面量值,不能取地址,只能放在赋值运算符的右边。例如,字面量、函数返回值(非左值引用)。
- C++11 引入的右值引用 (
&&
): 扩展了右值的概念,允许绑定到即将销毁的对象,用于实现移动语义,提高性能。
2. 运算符总结
算术运算符:
+
,-
,*
,/
,%
(取模,用于整数),+=
,-=
,*=
,/=
,%=
.- 注意整数除法
/
的截断行为。 - 取模运算符
%
的结果符号与被除数相同(在 C++11 之后)。
- 注意整数除法
逻辑运算符:
&&
(逻辑与),||
(逻辑或),!
(逻辑非)。- 短路求值:
&&
和||
具有短路特性。a && b
: 如果a
为假,则b
不会执行。a || b
: 如果a
为真,则b
不会执行。
- 短路求值:
位运算符:
&
(按位与),|
(按位或),^
(按位异或),~
(按位取反),<<
(左移),>>
(右移)。- 位运算符通常用于对整数类型进行操作。
- 左移
<<
相当于乘以 2 的幂次方,右移>>
对于无符号数相当于除以 2 的幂次方,对于有符号数,具体行为取决于编译器实现(算术右移或逻辑右移)。 - 复合赋值运算符:
&=
,|=
,^=
,<<=
,>>=
.
关系运算符:
==
(等于),!=
(不等于),>
,<
,>=
,<=
.- 用于比较两个表达式的值,返回布尔值
true
或false
。 - 注意区分赋值运算符
=
和等于运算符==
。
- 用于比较两个表达式的值,返回布尔值
自增自减运算符:
++
(自增),--
(自减)。- 前置形式 (
++a
,--a
): 先自增/自减,然后返回修改后的值。 - 后置形式 (
a++
,a--
): 先返回当前值,然后自增/自减。 - 前置形式通常比后置形式效率略高,因为后置形式需要保存原始值。
cppint a = 5; int b = ++a; // a 变为 6,b 变为 6 int c = a++; // c 变为 6,a 变为 7
- 前置形式 (
三目运算符 (条件运算符):
条件 ? 表达式1 : 表达式2
- 如果条件为真,则返回
表达式1
的值,否则返回表达式2
的值。 - 简化简单的
if-else
语句。 - 执行效率上,现代编译器可能会对
if-else
语句进行优化,实际差异可能很小。三目运算符更简洁。
cppint x = 10, y = 5; int max_val = (x > y) ? x : y; // max_val 的值为 10
- 如果条件为真,则返回
赋值运算符:
=
逗号运算符:
,
- 允许将多个表达式放在一个语句中,从左到右依次计算,整个逗号表达式的值是最后一个表达式的值。
cppint i, j; i = 10, j = 20, i + j; // 整个表达式的值是 30,但 i 和 j 的值被赋值
成员访问运算符:
.
(成员选择),->
(指针成员选择)。作用域解析运算符:
::
。sizeof 运算符: 返回变量或类型的大小(以字节为单位)。
类型识别运算符:
typeid
。内存管理运算符:
new
,delete
,new[]
,delete[]
。运算符优先级和结合性: 理解运算符的优先级和结合性对于正确理解表达式的计算顺序至关重要。查阅运算符优先级表。
3. 控制流语句总结
break
:- 跳出
switch
语句: 用于终止switch
语句块的执行,防止 case 穿透。 - 终止并跳出当前循环: 用于立即退出
for
、while
或do-while
循环。
- 跳出
continue
:- 结束当前循环迭代,进入下一次循环: 用于跳过当前循环体中
continue
之后的语句,直接开始下一次循环的迭代。
- 结束当前循环迭代,进入下一次循环: 用于跳过当前循环体中
嵌套:
if
语句嵌套:if
语句可以嵌套在另一个if
或else
语句块中,用于处理更复杂的条件判断。- 循环语句嵌套: 循环语句 (如
for
、while
) 可以相互嵌套,用于处理需要多层迭代的情况(例如,遍历二维数组)。 switch
语句嵌套:switch
语句可以嵌套在其他控制流语句中,但一般不建议过度嵌套,影响可读性。
goto
语句 (不推荐使用): 可以无条件跳转到程序中的标记位置。容易导致程序流程混乱,降低可读性和可维护性,应尽量避免使用。return
语句: 用于从函数中返回,可以返回一个值(如果函数有返回值类型)。
4. 宏定义
#define
: 预处理器指令,用于在预编译阶段进行文本替换。定义常量:
cpp#define N 60 // 将代码中所有出现的 N 替换为 60
定义宏函数 (类函数宏):
cpp#define SQUARE(x) ((x) * (x)) // 注意加括号,防止优先级问题 int result = SQUARE(5 + 2); // 预编译后变为 ((5 + 2) * (5 + 2))
宏定义的特点:
- 文本替换: 宏定义仅仅是文本替换,没有类型检查。
- 预编译阶段处理: 在编译之前进行处理。
- 没有作用域: 宏定义的作用域从定义处开始,直到文件末尾或遇到
#undef
指令。 - 不是变量或常量: 宏不是变量,也不占用内存空间。
- 不需要分号:
#define
指令末尾不需要加分号。
宏定义的优缺点:
- 优点:
- 代码复用: 可以定义常用的常量或代码片段。
- 提高效率: 对于简单的函数,宏展开可以避免函数调用的开销(但可能导致代码膨胀)。
- 条件编译: 可以使用
#ifdef
、#ifndef
、#else
、#endif
等进行条件编译。
- 缺点:
- 缺乏类型安全检查: 容易引入类型错误。
- 调试困难: 宏展开后的代码不易调试。
- 容易产生副作用: 特别是在宏函数中使用自增自减运算符时。
- 可读性差: 复杂的宏定义可能降低代码可读性。
- 优点:
替代方案:
- 常量: 优先使用
const
或constexpr
定义常量,具有类型检查。 - 内联函数: 优先使用
inline
函数替代简单的宏函数,既有宏的效率,又有函数的类型安全。 - 模板: 用于实现泛型编程。
- 常量: 优先使用
5. 指针的算术运算
指针可以进行加减运算: 表示指针指向的内存地址的偏移。
运算单位取决于指针类型: 指针加
n
,实际地址增加n * sizeof(指针所指向的数据类型)
字节。cppint arr[5] = {10, 20, 30, 40, 50}; int *ptr = arr; // ptr 指向 arr[0] ptr = ptr + 1; // ptr 指向 arr[1],地址增加了 1 * sizeof(int) ptr += 2; // ptr 指向 arr[3],地址增加了 2 * sizeof(int) double *dptr; dptr = dptr - 1; // dptr 指向前面的内存地址,偏移 sizeof(double) 字节
指针与整数的加减运算:
ptr + n
: 指针ptr
向后移动n
个元素。ptr - n
: 指针ptr
向前移动n
个元素。
指针的减法运算:
- 两个相同类型的指针相减,结果是它们之间相隔的元素的个数。
cppint arr[5] = {1, 2, 3, 4, 5}; int *p1 = &arr[1]; int *p2 = &arr[4]; int diff = p2 - p1```markdown int diff = p2 - p1; // diff 的值为 3 (arr[4] 的索引减去 arr[1] 的索引)
注意:指针运算的有效性:
- 确保指向有效的内存地址: 指针运算不能超出数组的边界或访问未分配的内存,否则可能导致程序崩溃或未定义的行为。
- 指针运算通常用于数组: 指针算术运算在遍历数组元素时非常有用。
- void 指针:
void*
指针可以指向任何类型的数据,但不能直接进行指针算术运算,需要先转换为具体的指针类型。
指针的比较运算:
- 可以使用关系运算符 (
==
,!=
,>
,<
,>=
,<=
) 比较两个指针的地址。 - 比较相同类型的指针: 比较它们指向的内存地址。
- 比较指向同一数组的指针: 可以判断它们在数组中的相对位置。
- 与空指针比较: 判断指针是否指向有效的内存地址 (
ptr == nullptr
或ptr != nullptr
)。
- 可以使用关系运算符 (
示例:
cpp#include <iostream> int main() { int arr[5] = {10, 20, 30, 40, 50}; int *ptr = arr; // 指向 arr[0] std::cout << "Initial pointer: " << ptr << ", value: " << *ptr << std::endl; ptr++; // 移动到下一个元素 std::cout << "Pointer after increment: " << ptr << ", value: " << *ptr << std::endl; ptr += 2; // 移动两个元素 std::cout << "Pointer after adding 2: " << ptr << ", value: " << *ptr << std::endl; int *endPtr = &arr[4]; std::cout << "Distance to the end: " << endPtr - ptr << std::endl; if (ptr < endPtr) { std::cout << "ptr is before endPtr" << std::endl; } return 0; }
6. 数组 (回顾与补充)
数组的定义:
- 相同数据类型元素的集合,存储在连续的内存位置。
- 定义时需要指定数组的大小。
- 数组名代表数组首元素的地址。
数组的初始化:
- 可以使用初始化列表进行初始化。
- 如果部分初始化,剩余元素将被初始化为 0。
- 可以省略数组大小,让编译器根据初始化列表推断。
cppint arr1[5] = {1, 2, 3, 4, 5}; int arr2[5] = {1, 2}; // arr2[2]、arr2[3]、arr2[4] 初始化为 0 int arr3[] = {1, 2, 3}; // 编译器推断大小为 3
访问数组元素:
- 使用下标运算符
[]
访问数组元素,下标从 0 开始。 - 例如,
arr[0]
访问第一个元素。
- 使用下标运算符
数组越界:
- 访问超出数组边界的元素会导致未定义的行为,可能导致程序崩溃或数据损坏。
- C++ 不会自动进行数组越界检查。
多维数组:
- 数组的元素也是数组。
- 例如,二维数组:
int matrix[3][4];
(3 行 4 列)。 - 访问二维数组元素:
matrix[row][col]
。
数组与指针的关系:
- 数组名在大多数情况下可以隐式转换为指向数组首元素的指针。
- 指针可以像数组一样使用下标运算符访问元素。
cppint arr[5] = {10, 20, 30, 40, 50}; int *ptr = arr; // ptr 指向 arr[0] std::cout << arr[2] << std::endl; // 访问数组元素 std::cout << ptr[2] << std::endl; // 指针像数组一样访问元素
将数组传递给函数:
- 数组作为函数参数传递时,会退化为指针。
- 需要额外传递数组的大小信息。
cppvoid printArray(int arr[], int size) { for (int i = 0; i < size; ++i) { std::cout << arr[i] << " "; } std::cout << std::endl; } int main() { int myArray[] = {1, 2, 3, 4, 5}; printArray(myArray, 5); return 0; }
作业:给你一个 int 型数组,请你使用指针的算术运算对数组中的元素进行操作:
- 如果是偶数就+1
- 如果是奇数就+2