C++从遗忘到入门(中)

简介: C++从遗忘到入门(中)

C++从遗忘到入门(上):https://developer.aliyun.com/article/1480761


  • 指针与数组


指针和数组名的异同

在C++中,数组名在绝大多数场景下可以看做是指针,在这些场景下数组名和指向该数组首个元素的指针是等价的。

int arr[5] = {1, 2, 3, 4, 5};
int * p1 = arr;     // arr 被当做指向数组首元素的指针
int * p2 = &arr[0];    // 取arr首个元素的地址
// 这种情况下 p1 和 p2 是等价的
if (p1 == P2) {      // 检测会通过
    cout << "p1,p2是等价的" << endl;  
    cout << *p1 << endl;  // 打印 1
    cout << *p2 << endl;   // 打印 1
}

// 使用指针访问数组
// 指针方式
cout << *(p1 + 1) << endl;  // 访问数组第二个元素,这种方式符合指针的计算规则
// 类似数组名的使用方式
cout << p1[1] << endl;// p1虽然是指针,索引访问方式依然有效,本质是*(p1 + 1)的语法糖


指针和数组名有区别的地方:

int arr[5] = {1, 2, 3, 4, 5};
int * p1 = arr;

cout << sizeof(arr) << endl;  // 打印结果:20 
cout << sizeof(p1) << endl;    // 打印结果:8
// sizeof(arr)为数组本身的大小,这里是 5个int占用20字节
// sizeof(p1)为指针本身大小,64位系统中占用8个字节


此外 &取地址运算符对于 指针和数组名的处理也是不同的:

cout << &arr << endl;      // 0x16b98aa40
cout << &arr + 1 << endl;    // 0x16b98aa54
cout << &arr[0] << endl;    // 0x16b98aa40
cout << &arr[0] + 1 << endl;  // 0x16b98aa44

// 可以看出 &arr 和 &arr[0] 的值是一样的,但是指针偏移1后
// (&arr + 1) 在 &arr 的基础上偏移了20(0x14)个字节
// (&arr[0] + 1) 在 &arr[0] 的基础上偏移了4个字节


对于数组名进行 & 取地址,得到的整个数组的地址,虽然值和首元素地址相同,但其指针类型是不同的。

  1. &arr 得到的类型是 int (*)[5] ,这是一个指向包含5个整数数组的指针
  2. &arr[0]得到的类型是 int *,这是一个整型指针


动态数组

前面介绍的数据都是静态数组,实际开发中,可能更希望更具实际需要动态申请指定长度的数组,这时就需要动态数组。因为标准库中提供了std::vector容器,提供了更加方便的动态数组解决方案,因此这里简单介绍下:

int * arr = new int[10];   // new操作符在堆内存中申请10个int类型大小的连续空间,并返回首地址
arr[0] = 1;
arr[1] = 2;
// ...
delete[] arr;      // new操作符申请的内存需要使用delete操作符释放,数组使用delete[]


多维数组的创建和释放比一维要复杂一些,下面是示例:

// 二维数组的动态创建 & 释放
int rows = 5; // 行数
int cols = 3; // 列数

// 动态创建二维数组
int ** array = new int*[rows]; // 创建行指针
for (int i = 0; i < rows; ++i) {
    array[i] = new int[cols]; // 为每行分配内存
}

// 初始化二维数组
for (int i = 0; i < rows; ++i) {
    for (int j = 0; j < cols; ++j) {
        array[i][j] = i * cols + j; // 或者任何其他的赋值逻辑
    }
}

// 使用二维数组,例如打印它
for (int i = 0; i < rows; ++i) {
    for (int j = 0; j < cols; ++j) {
        std::cout << array[i][j] << ' ';
    }
    std::cout << std::endl;
}

// 动态释放二维数组
for (int i = 0; i < rows; ++i) {
    delete[] array[i]; // 释放每行的内存
}
delete[] array; // 释放行指针数组的内存


数组和指针结合使用时会有一些容易出错的点:

int * p[10];  // p是一个包含10个int变量的数组
int (*p)[10];   // p是一个指向拥有10个int变量的数组的指针

// * [] 两个运算符的优先级不同,[]的优先级更高
// 第一个语句声明了 p[10], int * 是类型
// 第二个语句有括号改变了优先级,因此 p 是一个指针,剩下的部分定义了类型

// 下面函数指针也会有类似的定义
int (*pf)(int, int); // pf是指向形如 int func(int, int) 的函数指针



函数

在 C++ 中,函数是一段执行特定任务的代码块,它具有一个名字,可以接受输入参数(也可以不接受),并可以返回一个值(也可以不返回,即返回类型为 void)。函数的主要目的是使代码更模块化、更易于管理,并且可以重用。


  • 函数基础


C++的完整函数定义包括以下几个要素:

  1. 返回类型:函数可能返回的值的数据类型。如果函数不返回任何值,则使用关键字 void。
  2. 函数名:用于识别函数的唯一名称。
  3. 参数列表:括号内的变量列表,用于从调用者那里接收输入值。如果函数不接受任何参数,则参数列表为空。
  4. 函数体:花括号 {} 内包含的代码块,当函数被调用时将执行这些代码。
// 这是一个简单函数定义
int add(int a, int b) {
    return a + b;
}

// 调用
int sum = add(3, 7);  // sum值为10


此外,函数定义时需要定义函数原型(也叫函数声明),函数原型告知编译器关于函数的名称、返回类型、参数,但是不提供函数体。一般函数原型都定义在头文件中,包含该头文件即可调用相关函数。

// 下面是一个函数原型的定义
int draw(int, int);        // 函数原型的参数列表可以省略参数名
int draw(int width, int height);// 建议加上参数名,可以更直观的了解参数含义


  • 参数传递


C++中函数的参数传递方式包含:

  1. 值传递
  2. 指针传递
  3. 引用传递(特指左值)
  4. 右值传递

传递方式

值传递

该传递方式中函数的实参的值被复制到形参中。函数操作的是实参的副本(拷贝)。

见下例:

void swap(int a, int b) {
    int tmp = a;
    a = b;
    b = tmp;
}

int x = 5; 
int y = 7;
swap(x, y);      // x = 5  y = 7

调用swap(x, y)时,函数swap接受两个参数a(x的拷贝),b(y的拷贝),此时在函数中对ab的操作不会影响到外部的实参 x、y。


指针传递

在该传递方式中函数的实参的地址被传递给了形参,本质其实是指针类型的值传递。因为指针能操作其对应的地址的值,因此可以通过指针完成对实参的修改。

上面的swap函数并没有实际完成其命名的功能(交换两个变量的数值),这里利用指针传递改造,见下面代码:

void swap(int * a, int * b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

int x = 5;
int y = 7;
swap(&x, &y);      // x = 7  y = 5


对于需要传递数组参数的场景,指针传递是唯一的选择,数组传参的示例如下:

int sum(int arr[], int size);// 定义1,这种定义的好处是清晰,调用者一看就知道传递数组指针

int sum(int * arr, int size);// 定义2,这种定义更符合数组传参的本质

// 特殊说明
// 不管定义1还是定义2,通过参数传递数组指针后,数组指针(前面介绍过,即数组名)会退化为首个元素
// 的地址指针,因此一定要通过size参数传递数组的大小给函数


引用传递(左值传递)

传递方式中形参成为实参的别名(引用),所以任何对形参的操作实际上都是在实参上进行的。引用就是别名。

编译器在底层可能会使用指针来实现引用,但会提供更严格的语义和更简单的语法。

引用的声明方式如下:

int a = 5;
int & ra = a;  // ra的类型是 int&(引用),必须声明时立即初始化

int b = 6;
ra = b;      // 非法,引用变量不支持重新赋值


依然以交换函数swap举例:

void swap(int & a, int & b) {
    int tmp = a;
    a = b;
    b = tmp;
}

int x = 5;
int y = 7;
swap(x, y);      // x = 7 y = 5


右值传递

右值传递同引用传递类似,是传递右值引用到函数内部的传递方式。主要被用来实现移动语义和完美转发。详细内容请自行搜索。在下面介绍类的移动语义的部分会有涉及右值。

左值(lvalue): 左值是指表达式结束后依然存在的持久性对象,可以出现在赋值语句的左边。 左值可以被取地址,即可以通过取地址运算符&获取其地址。 通常,变量、数组元素、引用、返回左值引用的函数等都是左值。 右值(rvalue): 右值是指表达式结束后不再存在的临时对象,不能出现在赋值语句的左边。 右值不能被取地址,即不能通过取地址运算符&获取其地址。 通常,字面量、临时对象、返回右值引用的函数等都是右值。

C++11引入了右值引用(rvalue reference)的概念,通过&&来声明一个右值引用。右值引用可以绑定到临时对象,从而支持移动语义和完美转发。移动语义允许将资源(如动态分配的内存)从一个对象“移动”到另一个对象,而不是进行昂贵的复制操作。完美转发允许将参数以原样传递给其他函数,避免不必要的拷贝。


总的来说,C++11中的左值和右值概念更加严格和明确,为语言引入了更多的灵活性和性能优化的可能性。


拓展:

  1. 纯右值
  2. 将亡值
  3. 泛左值
  4. std::move
  5. 类型萃取


参数修改保护

对于使用指针传递方式和引用方式传递参数的函数,因为函数内部有修改外部变量数据的能力,因此使用不当可能出现问题。对于一个命名为 printInfo 函数大概率只会使用数据而不会修改数据,应该避免在之后的维护中出现修改参数的情况,这时可以通过 const 关键字来修饰函数的参数,达到禁止函数修改参数的目的。示例如下:

// 下面指针传递示例
void printInfo(int arr[], int size); // 内部可修改arr

void printInfo(const int arr[], int size); // 内部不可修改arr

// 下面是引用传递示例
void printInfo(std::string& info); // 内部可以修改info

void printInfo(const std::string& info); // 内部不可以修改info


传参方式选择原则

在函数定义时,选择合适的参数传递方式对于代码的性能和可读性至关重要。以下是一些常见的实践做法。

对于仅使用参数的值,并不会进行修改的函数而言,应尽量遵循下面的原则:

  1. 如果数据对象很小,如内置数据类型或者小型结构,这按值传递。
  2. 如果数据对象是数组,这使用指针,因为这是唯一的选择,并将指针声明为常量指针。
  3. 如果数据对象较大的结构,则使用常量指针或者const引用,可以节省复制结构所需要的时间和空间,提高程序的效率。
  4. 如果数据对象是类对象,则使用const引用。类设计的语义常常要求使用引用。这是C++增加引用的主要原因。因此传递类对参数的标准方式是按引用传递。

而对于需要通过参数修改原来变量值的函数,应遵循下面的原则:

  1. 如果数据对象是内置数据类型,则使用指针。
  2. 如果数据对象是数组,则只能使用指针。
  3. 如果数据对象是结构,则使用指针或者引用。
  4. 如果数据对象是类对象,则使用引用。


  • 函数重载


函数重载是一种允许多个具有相同名称但参数列表不同的函数共存的特性。函数重载允许使用相同的函数名来执行不同的任务,只要它们的参数类型或数量不同即可。编译器通过查看函数的参数列表(也称之为函数签名)来区分重载的函数。

// 下面是一组重载函数,同样是计算两个数的和,针对不同类型提供了不同的定义
int add(int a, int b) {        // 版本1
    return a + b;
}      

float add(float a, float b) {    // 版本2
    return a + b;
}    

double add(double a, double b) {  // 版本3
    return a + b;
}  

add(1, 2);      // 匹配版本1
add(1.0f, 2.0f);   // 匹配版本2
add(1.0, 2.0);    // 匹配版本3

add(1.0f, 2.0)    // 匹配 ???(匹配版本3,原因可以搜索 ”重载解析“)


重载规则

先介绍一个概念:函数签名。在C++中函数签名包含两个部分:

  1. 函数名称
  2. 参数列表:包括参数的类型、数量和顺序。注意一个参数是否使用引用并不能作为签名不同的依据。参数是否是 const 能作为不同的依据

函数重载遵循下面的原则:

  1. 函数签名必须不同
  2. 作用域必须相同:重载的函数必须处于同一个作用域,否则它们被视为不同作用域中的不相关函数。
  3. 最佳实践是尽量保持重载函数的明确性,避免产生容易混淆的重载集合。

一些注意点:

// 下面两个版本的函数不算重载,因为两者调用时的表达式都是 add(x, y), 编译器无法区分
int add(int a, int b);
int add(int & a, int & b);

// 下面两个版本算重载,编译器会根据实参是否是常量来匹配更合适的版本
int add(const int a, const int b);
int add(int a, int b);


拓展:

重载函数匹配规则


  • 函数模板


函数模板的声明

C++ 中泛型编程的基础构建块。它们允许程序员编写与类型无关的代码,从而使得相同的函数逻辑可以应用于不同的数据类型。函数模板通过模板参数化来实现,在实例化时,编译器根据传递给模板的实际参数类型生成具体的函数实例。

对于在函数重载一节提到过的add函数,可以看到所有add函数的实现代码都是一样的,只是数据类型不一致。这里通过函数模板来实现同样的功能。

template <typename T>
T add(T a, T b) {
    return a + b;
}

// 多类型的定义
template <typename T1, typename T2>
void funcName(T1 a, T2 b);


下面是一个实际示例:

#include <iostream>
using namespace std;

// 函数原型
template <typename T>
T add(T a, T b);    

int main() {
    cout << add(1, 2) << endl;       // 3
    cout << add(1.0f, 2.1f) << endl;   // 3.1
    cout << add(1.0, 3.2) << endl;    // 4.2

    return 0;
}

template <typename T>
T add(T a, T b) {
    return  a + b;
}


注意,编译器在编译时会根据调用的参数类型生成对应的实际函数,这个过程被称为模板的实例化,该示例中实际会生成3个版本的add函数,只是不可见而已。另外使用模板不会减小最终的可执行程序,因为最终程序中依然会包含多个版本的add函数实例。


重载的模板

依然以add函数举例,现在需要一个计算3个数据和的函数,并且也可能需要多种类型的版本,可以这么做:

// 函数原型
template <typename T>
T add(T a, T b);

template <class T>      // 声明模板时 typename 和 class 等价
T add(T a, T b, T c);

// 函数定义略


模板的局限

考虑下面的模板:

template <typename T1, typename T2>
void funcName(T1 x, T2 y) {
    ...
    ?type? temp = x + y;
    ...
}


temp这行应该怎么声明呢?这个类型取决于 x + y 的结果,可能是int, double,甚至更加复杂。C++11为了解决这个问题提供了 decltype 关键字,可以这样使用:

template <typename T1, typename T2>
void funcName(T1 x, T2 y) {
    ...
    decltype(x + y) temp = x + y;
    ...
}


关于 decltype 如何确定最终类型,可以自行搜索,这里不展开。

下面考虑另一个模板:

template <typename T1, typename T2>
?type?funcName(T1 x, T2 y) {
    ...
    return x + y;
}


这里的返回值类型应该怎么声明?好像可以使用 decltype(x + y),但这里不行,因为这里还未定义x、y,编译器无法使用这种方式推断。C++11新增了新的语法返回类型后置解决该问题:

// 正常函数声明
int add(int a, int b);
// 返回类型后置声明
auto add(int a, int b) -> int;

// 利用该语法可以这么声明上面的函数(推荐C+11中使用)
template <typename T1, typename T2>
auto funcName(T1 x, T2 y) -> decltype(x + y) {
    ...
    return x + y;
}

// C++14及以后得标准拓展了auto的类型推导能力
auto funcName(T1 x, T2 y) {
    ...
    return x + y;
}


更多拓展:

  1. 模板函数具体化、全特化 (Full Specialization)
  2. auto类型推导规则(区分C+11、C++14)
  3. decltype类型推导规则
  4. 模板元编程


  • 回调函数


在复杂的应用程序中,回调函数是经常需要使用的技术。在C++中要实现回调函数有以下几种方式。

函数指针

函数名本身就是函数的指针。函数指针在定义时必须指明所指向函数的类型,包括返回类型和参数列表。

以下是函数指针的定义语法:

// 返回类型 (*指针变量名)(参数列表);

// 示例
int add(int a, int b) {
    return a + b;
}

int (*pf)(int, int) = add;  // 可以这么理解定义:因为(*pf)表示函数,那么pf就是函数的指针

// 类似数组,函数指针也有两种使用方式
cout << (*pf)(2, 3) << endl;    // 5 指针使用方式
cout << pf(2, 3) << endl;    // 5 直接作为函数名使用

// 函数指针的定义一般都不怎么直接,使用也不方面

// 经典C++中可以使用typedef简化这个定义
typedef int (*p_fun)(int, int);    // 现在p_fun就是一种类型名称
p_fun pAdd = add;          // 精简很多

// 现代C++提供了 using 语法让这个过程更加直观,推荐使用
using p_fun = int (*)(int, int);   // 可读性更强
p_fun pAdd = add;

// auto大杀器
auto pAdd = add;          // 懒人利器


知道怎么定义函数指针类型后,就可以定义支持回调函数的函数了,如下:

#include <iostream>

void callBack(int costTimeMs);
void work(void (*pf)(int));

int main() {
    work(callBack);
}

void callBack(int costTimeMs) {
    using namespace std;

    cout << "costTime:" << costTimeMs << endl; 
}

void work(void (*pf)(int)) {
    std::cout << "do some work" << std::endl;
    // ...
    pf(123);  // (*pf)(123) 也ok
}


std::function

上面介绍的函数指针在定义时不怎么直观,C++标准库中提供了std::function 容器来简化这个过程。其实现技术原理可以自行搜索。这里给出代码示例:

#include <functional>
#include <iostream>

using namespace std;

void callBack(int costTimeMs) {
    cout << "costTime:" << costTimeMs << endl; 
}

void work(function<void(int)> callBack) {
    callBack(1234);
}

int main() {
    function<void(int)> func = callBack;
    work(func);
    return 0;
}


更多方式

C++是面向对象的语言,回调的场景更多的涉及到对象。对此C++提供了 函数对象(Functors成员函数指针和 std::bind 作为回调函数。这里先不展开。



C++从遗忘到入门(下):https://developer.aliyun.com/article/1480759


目录
相关文章
|
1月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
38 2
C++入门12——详解多态1
|
1月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
79 1
|
1月前
|
存储 安全 编译器
【C++打怪之路Lv1】-- 入门二级
【C++打怪之路Lv1】-- 入门二级
23 0
|
1月前
|
自然语言处理 编译器 C语言
【C++打怪之路Lv1】-- C++开篇(入门)
【C++打怪之路Lv1】-- C++开篇(入门)
24 0
|
1月前
|
分布式计算 Java 编译器
【C++入门(下)】—— 我与C++的不解之缘(二)
【C++入门(下)】—— 我与C++的不解之缘(二)
|
1月前
|
编译器 Linux C语言
【C++入门(上)】—— 我与C++的不解之缘(一)
【C++入门(上)】—— 我与C++的不解之缘(一)
|
1月前
|
编译器 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
30 0
|
1月前
|
程序员 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
32 0
|
1月前
|
存储 算法 C++
C++入门10——stack与queue的使用
C++入门10——stack与queue的使用
40 0
|
1月前
|
存储 C++ 容器
C++入门9——list的使用
C++入门9——list的使用
18 0