概述
本专栏为C++的学习笔记,从最基础的学起,每小节均有代码实现,有想学习的同学可以跟着敲一遍。
第一章学习点:
- 参数传递的不同方式
- 函数或者方法返回的不同模式
- 模板函数
- 递归函数
- 常量函数内存分配和释放 new 和 delete
- 异常处理
- 类与模板类
- 类的公有成员、保护成员、私有成员
- 友元
- 操作符重载
- 标准模板库
1.1 引言
一个程序判断它是否写的完美我们需要从几个方面看:
- 它是否正确
- 它是否易读
- 它是否有完善的文档
- 它容易修改吗
- 它在运行需要多大内存
- 它的运行时间多长
- 通用性如何
- 可以在多种计算机上运行吗
1.2 函数与参数
1.2.1 传值参数
一、示例代码
函数abc
/*
程序1-1
*/
int abc (int a , int b , int c) {
return a + b * c;
}
在程序1-1中,a、b、c是函数abc的形参(formal parameter)。
调用:
/*
程序1-2
*/
z = abc(2,x,y)
其中2,x,y 是对应的实参(actual parameter)。
其原理为调用abc(2,x,y)时,a被赋值2,b被赋值x,c被赋值y。作为值的传递。abc函数中做任何操作均不会影响x,y原本的值。
1.2.2 模板函数
假设我们希望另一个函数来实现类似1-1的表达式,不过这次a、b、c是float类型
,结果也是float,此时我们可以使用模板函数。
/*
程序1-3
*/
template<class T>
T abc (T a , T b , T c) {
return a + b * c;
}
在C++中,调用模板函数时编译器通常会根据实参类型自动推导
模板参数的类型,也可以显式指定模板参数。对于你提供的abc
模板函数,有以下几种调用方式:
- 自动类型推导调用(最常用):
int result = abc(1, 2, 3); // 推导T为int
double result2 = abc(1.5, 2.5, 3.5); // 推导T为double
- 显式指定模板参数(当需要强制类型或推导不明确时):
auto result3 = abc<double>(1, 2, 3); // 显式指定T为double
- 多类型参数的注意事项:
// 错误:所有实参必须为同一类型(T)
// auto error = abc(1, 2.5, 3); // 无法同时推导T为int和double
// 正确:显式转换或统一类型
auto correct = abc<double>(1, 2.5, 3); // 显式指定T为double
一、示例代码
以下是完整的调用示例:
#include <iostream>
template<class T>
T abc(T a, T b, T c) {
return a + b * c;
}
int main() {
// 自动推导类型
int intResult = abc(1, 2, 3); // T=int
double doubleResult = abc(1.5, 2.5, 3.5); // T=double
// 显式指定类型
auto mixedResult = abc<double>(1, 2.5, 3); // T=double
std::cout << "intResult: " << intResult << std::endl;
std::cout << "doubleResult: " << doubleResult << std::endl;
std::cout << "mixedResult: " << mixedResult << std::endl;
return 0;
}
二、关键要点
- 类型一致性:所有传入的实参必须能隐式转换为同一类型
T
- 自动推导:优先使用
自动推导
,简洁且不易出错 - 显式指定:在类型不匹配或需要
强制转换
时使用 - 返回值类型:返回值类型由模板参数
T
决定,需注意精度丢失问题
如果需要处理不同类型的参数,可以修改模板函数设计(例如使用多个模板参数)。
1.2.3 引用参数
在 C++ 中,template<class T> T abc(T& a, T& b, T& c)
里的非 const
引用参数(T&
),是可以修改实参的,下面详细解释原理和效果:
一、引用参数的本质
- 引用(
&
)可以理解成“变量的别名”,绑定实参后,函数里操作a
/b
/c
,直接等同于操作传入的实参。 - 对比“值传递”(传参时拷贝一份数据给函数),引用传递不拷贝数据,而是直接关联到实参,修改引用就会影响实参。
二、修改实参的示例
看这段代码就清楚了:
/*
程序 1-4
*/
#include <iostream>
using namespace std;
template<class T>
T abc(T& a, T& b, T& c) {
// 这里对引用参数 a 进行修改
a = a + 1;
return a + b * c;
}
int main() {
int x = 1, y = 2, z = 3;
// 调用函数时,a 绑定 x,b 绑定 y,c 绑定 z
int result = abc(x, y, z);
// x 的值被修改了!输出 x=2
cout << "x = " << x << endl;
cout << "result = " << result << endl;
return 0;
}
运行后:
x
原本是1
,但函数里a = a + 1
直接修改了x
,所以x
最终变成2
;- 计算逻辑
a + b * c
也会用修改后的a
(即2
)参与,结果result = 2 + 2*3 = 8
。
三、和 const 引用
的区别
如果参数是 const T&
(常量引用),比如:
/*
程序 1-5 利用常量引用参数计算一个表达式
*/
template<class T>
T abc(const T& a, const T& b, const T& c) {
// 错误!const 引用禁止修改
// a = a + 1;
return a + b * c;
}
加了 const
后,函数里不能修改 a
/b
/c
关联的实参,能避免意外修改数据,更安全。
四、什么时候用非 const
引用?
- 需要修改实参时:比如函数要“同时返回计算结果,又修改入参状态”(像某些累加逻辑、交换值场景);
- 传递大对象,想避免拷贝且要修改它:比如自定义的
Matrix
类,传引用比传值高效,且函数需要修改矩阵内容。
但要注意:如果调用时传的是字面量、const
变量,会编译报错,因为非 const
引用不能绑定到“只读数据”。
简单说:T&
引用参数就像“实参的直连通道”,函数里对引用的修改,会直接反映到实参上,用的时候要小心别不小心改坏了外部数据
1.2.4 常量引用参数
C++ 还提供了另外一种参数传递模式——常量引用(const reference)。这种模式指明的引用参数不能被函数修改。例如,在程序 1-4 中,a、b 和 c 的值没有变化,因此我们可以重写这段代码,如程序 1-5 所示。
/*
程序 1-5 利用常量引用参数计算一个表达式
*/
template<class T>
T abc(const T& a, const T& b, const T& c)
{
return a + b * c;
}
用关键字 const 来指明函数不可修改的引用参数,这在软件工程方面具有重要意义。函数头告诉用户该函数不会修改实参。
采用程序 1-6 的语法,我们可以得到程序 1-5 的一个更通用的版本。在新的版本中,每个形参可以是不同的数据类型,而函数返回值的类型与第一个形参类型相同。
/*
程序 1-6 比 程序 1-5 的一个更通用的版本
*/
template<class Ta, class Tb, class Tc>
Ta abc(const Ta& a, const Tb& b, const Tc& c)
{
return a + b * c;
}
1.2.5 值返回
函数返回数据时的底层逻辑和使用场景 :
1. 基础:值返回(默认情况)
函数返回一个具体的值时(比如 int func()
返回 return 10;
),返回的本质是复制:
- 函数里计算出的结果存在「局部临时变量」里,函数结束时,这些临时变量、局部变量会被销毁(内存释放)。
- 为了让调用者拿到结果,会把这个值复制一份,传递到调用环境中。
- 缺点:如果返回的是复杂对象(比如大的
class
),复制过程会有额外性能开销。
2. 引用返回(&
后缀)
给返回类型加 &
,就变成引用返回(比如 int& func()
),它的核心是「不复制,直接返回引用」:
- 返回的是实参的引用(可以理解为“别名”),不会额外复制数据。
- 函数结束时,虽然局部变量会销毁,但引用的实参不在函数局部作用域(比如是调用时传入的外部变量),所以引用依然有效。
- 场景:想直接操作外部变量、避免值复制的性能消耗时用,但要注意返回的引用不能指向函数内的局部变量(会销毁,引用就失效了)。
3. const 引用返回(const T&
)
在引用返回前加 const
,变成只读的引用返回:
- 和普通引用返回类似,但返回的引用被
const
修饰,调用者拿到的是「只读别名」,不能直接修改它的值。 - 场景:常用于返回复杂对象的“只读视图”,既避免复制开销,又防止调用者意外修改原始数据。
简单总结:
- 想简单传值、不关心性能,用值返回;
- 想直接操作外部变量、追求性能,用引用返回(但别返回局部变量的引用);
- 想“只读访问”外部变量、又不想复制,用const 引用返回 。
这些返回形式的设计,本质是为了灵活控制函数返回数据的“传递方式”和“访问权限”,优化性能或保证数据安全
1.3 捕获异常
异常是表示程序出错的信息,捕获方式如下:
int main(){
try {cout << abc(2,0,4) << endl;}
catch (char* e){
cout << "The parameters to abe were 2,0,and 4"<< end1 ; .
cout << "An exception has been thrown" << endl;
cout << e << end1;
return 1;
}
return 0;
}
1.4 动态存储空间分配
1.4.1 操作符new
核心功能
new
是 C++ 实现动态内存分配的关键字,程序运行时“按需”向系统申请内存,返回的是指向分配内存的指针,让你能灵活管理内存(不像数组等静态分配,编译时就固定大小)。
分步逻辑(以 int 类型为例)
-
声明指针:
int *y;
先定义一个指针y
,但它还没指向有效内存(野指针状态,直接用会出错 )。 -
动态分配内存:
y = new int;
这行是关键!new int
会向系统“要”一块能存int
的内存,然后把这块内存的地址交给指针y
,此时y
就指向了合法内存空间。 -
使用分配的内存:
*y = 10;
通过指针y
的“解引用”(*y
),操作它指向的内存,给这块空间存值10
。
简化写法
为了少写代码,C++ 允许“声明 + 分配 + 赋值”一步到位:
-
int *y = new int(10);
声明指针y
的同时,用new int(10)
分配内存,并直接把10
放到刚申请的空间里。 -
也可以拆成:
int *y; y = new int(10);
效果和上面一样,先声明指针,再分配内存并初始化值。
注意
动态分配的内存,用完记得用 delete
释放(delete y;
),否则会造成内存泄漏(系统给的内存一直被占用,程序结束才释放,浪费资源),这也是动态内存管理的“坑点”,需要成对用 new
和 delete
。
以上是 C++ 动态内存管理的基础用法。
1.4.2 一维数组
一个长度为n的一维浮点数组可以按如下方式创建,动态存储分配:
float *x = new float[n];
操作符new为n个浮点数分配了存储空间。
1.4.3 异常处理
float *x = new float[n];
执行该语句可能会出现,对于n个浮点数,计算机没有足够的内存分配。这样的情况下,操作符new不会分配内存
,而是会抛出异常bad_alloc
。利用try-catch
能够捕获该异常。
float *x ;
try{x = new float [n];}
catch(bad_alloc e){
cerr << "Out of Memory" <<endl;
exit(1);
}
1.4.4 操作符 delete
动态分配的内存在不需要的时候我们应该要把它释放
。
delete y;
delete []x;
1.4.5 二维数组
一、核心概念:指针与二维数组的关系
C++采用多种机制来说明二维数组,但是这些机制大都要求编译的时候就知道二维大小。
形参
是一个二维数组时,必须指定其大小。例如a[][10]合法,a[][]不合法
。
为了使我们使用数组时候大小合适,一般采用动态存储分配
。
举例:
假设已知列数为5,如何动态分配存储空间
char(*c)[5];
try{c=new char[n][5];}
catch(bad_alloc)
{//仅当new失败时才会进入
cerr <<"Out of Memory" <<endl;
exit(1);
}
举例:
假设已知列数也未知,如何动态分配存储空间
首先我们需要明白一个概念,如果列数也是未知的
,那不可能
仅调用一次new就能创建这个二维数组。要构造这样的二维数组,可以把它看做是若干个行
构成的,每一行都是能用一个new
来创建的一维数组。
先明确一个重要逻辑:二维数组在指针层面,本质是“指向指针的指针” 。比如,定义 char **x;
,这里 x
是指向指针的指针,x[0]
、x[1]
等则是指向每一行首元素的指针。打个比方,把二维数组想象成一个“指针数组”,每个元素又是指向某一行数据的指针,这样就能通过多层指针操作,灵活管理二维数组的行和列 。
二、动态创建二维数组的实现(以程序 1-10 为例)
1. 函数设计思路
我们有一个模板函数 make2DArray
,作用是动态创建类型为 T
的二维数组。参数里,T **&x
是引用传递的二维指针(这样函数内对 x
的修改能影响外部),numberOfRows
是行数,numberOfColumns
是列数 。
2. 分步拆解代码逻辑
程序 1-10
template <class T>
bool make2DArray(T **&x, int numberOfRows, int numberOfColumns) {
try {
// 第一步:创建行指针数组
x = new T *[numberOfRows];
// 第二步:为每一行分配列空间
for (int i = 0; i < numberOfRows; ++i) {
x[i] = new T[numberOfColumns];
}
return true;
} catch (bad_alloc) {
return false;
}
}
- 创建行指针数组:
x = new T *[numberOfRows];
这行代码,先给“行指针”分配空间。比如要创建 3 行的二维数组,就会生成一个包含 3 个指针的数组,每个指针后续指向一行数据的首地址 。 - 为每行分配列空间:通过
for
循环,遍历每一个行指针x[i]
,执行x[i] = new T[numberOfColumns];
为每一行单独分配列的空间。假设列数是 5,就会给每一行开辟能存 5 个T
类型元素的空间,最终形成类似图 1-2 里 3×5 数组的存储结构 。 - 异常处理:用
try - catch
捕获new
可能抛出的bad_alloc
异常(内存分配失败时触发),分配成功返回true
,失败返回false
,让调用者知道创建结果 。
三、使用场景与注意事项
1. 使用场景
这种动态创建二维数组的方式,特别适合 行数或列数不确定 的场景。比如处理用户输入决定数组大小、读取文件数据动态构建二维存储结构等。相比静态二维数组(如 int arr[3][5]
,大小编译时确定),动态创建更灵活,能根据程序运行时的实际需求分配内存 。
2. 注意事项
- 内存释放:动态分配的内存,用完必须手动释放!否则会内存泄漏。释放时要“逆序”:先遍历每行,释放行的内存(
delete[] x[i]
),最后释放行指针数组(delete[] x
) 。 - 异常安全:虽然代码里做了异常捕获,但实际应用中,要考虑异常发生后程序的状态。比如内存只分配了一部分就抛异常,需妥善处理未完成的分配,避免内存混乱 。