【C++从0到王者】第一站:从C到C++(下)

简介: 【C++从0到王者】第一站:从C到C++(上)

2>引用做返回值

我们先来看一下普通的变量做返回值

int Count()
{
  int n = 0;
  n++;
  return n;
}
int main()
{
  int ret = Count();
  return 0;
}

在这段代码中,返回n的过程,伴随着函数栈帧的销毁,n也被销毁了,那么n是如何返回给ret的呢?其实是因为编译器会先将n的值赋给一个寄存器(或一个临时变量),然后编译器再将这个临时变量交给ret。这里的不一定是一个寄存器,因为寄存器的字节比较小,只有四个字节或八个字节,数据量太大的时候可能存不下

但是如果我们加上一个static呢?,也就是下面这段代码,那么此时还会继续创建临时变量吗?

int Count()
{
  static int n = 0;
  n++;
  return n;
}
int main()
{
  int ret = Count();
  return 0;
}

答案是还是会的,虽然这个n并不受到栈帧的影响,但是编译器也不想搞这些特例,所以还是会继续创建一个临时变量的。一旦使用一个特例,对于编译器后面的一些优化的实现就变得更加复杂。

其实,也就是说,编译器是否创建临时变量并不取决于这个变量出了作用域后是否还存在,而是取决于返回值。如果传值返回的,无论是局部变量还是静态区的变量,那么就都要创建一个临时变量来拷贝一下。

那么如果不想要生成这个临时变量,有没有办法呢?我们说是有的,使用引用作为返回值,就不会产生临时变量。这也是传引用返回的第一大好处,减少拷贝,提高效率。

如下代码所示

#include<iostream>
using namespace std;
int& Count()
{
  int n = 0;
  n++;
  return n;
}
int main()
{
  int ret = Count();
  printf("%d", ret);
  return 0;
}

这段代码返回的是n的引用,也就是n的别名。我们可以将他想象为一个变量,也就是说,在主函数中创建了ret这个变量,然后再Count函数结束后,将n这块空间给返回,这里就相当于一个赋值操作。直接让n的别名赋给了ret

这种情况我们类似于引用的这种使用

#include<iostream>
using namespace std;
int main()
{
  int n = 0;
  int& a = n;
  int ret = a;
  cout << ret << endl;
}

也就是直接将n的引用赋给ret

但是上面的程序其实存在一些问题的

上面打印的ret的值其实是不确定的,因为n是一个局部变量。

如果Count函数结束,栈帧销毁,没有清理栈帧,那么ret的值侥幸是正确的

如果Count函数结束,栈帧销毁,清理栈帧,那么ret的结果是随机值

这里其实就相当于一个野指针

如果为了防止野指针的问题,我们可以将这个n改为静态的n

#include<iostream>
using namespace std;
int& Count()
{
  static int n = 0;
  n++;
  return n;
}
int main()
{
  int ret = Count();
  printf("%d", ret);
  return 0;
}

这样的话,这个n就不会存在万一栈帧被清理掉的话,而导致n的值出现问题的情况

我们可以测试一下传引用返回的效率

#include<iostream>
#include <time.h>
using namespace std;
struct A 
{
  int a[10000]; 
};
A a;
// 值返回
A TestFunc1() 
{
  return a; 
}
// 引用返回
A& TestFunc2() 
{ 
  return a; 
}
void TestReturnByRefOrValue()
{
  // 以值作为函数的返回值类型
  size_t begin1 = clock();
  for (size_t i = 0; i < 100000; ++i)
    TestFunc1();
  size_t end1 = clock();
  // 以引用作为函数的返回值类型
  size_t begin2 = clock();
  for (size_t i = 0; i < 100000; ++i)
    TestFunc2();
  size_t end2 = clock();
  // 计算两个函数运算完成之后的时间
  cout << "TestFunc1 time:" << end1 - begin1 << endl;
  cout << "TestFunc2 time:" << end2 - begin2 << endl;
}
int main()
{
  TestReturnByRefOrValue();
  return 0;
}

在这段代码中,我们定义了一个结构体,他有10000个元素的数组,然后我们定义一个全局的结构体类型的变量。我们使用两个函数直接返回这个结构体变量。最终我们测得如下结果

可见使用传引用返回确实可以减少拷贝,提高效率

传引用返回,传的是一个别名。我们在使用普通的别名的时候,我们可以直接将别名赋给一个变量,这样这个变量就直接接收了这个值,我们在上面也是这样做的

别名还有一种使用方式是,给这个别名在取一个别名,类似于如下的代码

这里的b就类似于n返回的别名,我们接收这个别名的时候,不仅可以使用一个整型变量来接收,还可以再取一次别名,这样话,ret就直接可以操控n的这块空间

类似于上面的想法,可见我们使用引用作为返回值的时候,还可以使用引用。

#include<iostream>
using namespace std;
int& Count()
{
  int n = 0;
  n++;
  return n;
}
int main()
{
  int& ret = Count();
  cout << ret << endl;
  return 0;
}

我们使用vs2022环境是没有清理栈帧的。所以输出结果为1

但是如果我们使用一些其他方式去破坏了栈帧,就会出现其他的情况

如下代码是因为,我们虽然栈帧销毁了,但是vs并没有清理栈帧。所以我们可以直接打印出11,而我们继续调用Count,因为之前并没有清理栈帧,我们继续再原来的函数上重新建立的栈帧,而刚好由于函数是同一个函数,所以建立的栈帧也是一样的,刚好就是再原来的ret上改成了21

如果我们中间使用其他函数去破坏栈帧,覆盖原来的值呢?那么值就变的更加奇怪了,将变成随机值

我们也可以发现,这种行为都是很危险的。

但是如果上面的都是用静态的,那么就不危险了。因为不受到栈帧清理的影响了

总结:

1.基本任何场景都可以用引用传参

2.谨慎用引用做返回值,出了函数作用域,对象不在了,就不能用引用返回,还在就可以用引用返回

对于引用作为返回值,其实我们还有一种用法,类似于如下的代码所示,我们使用引用的时候,不仅可以将引用作为右值,然后左值可以直接接收或者继续引用。也可以使用将引用作为左值,这样代表的就是修改这块空间的值

得益于这样的思路,当我们曾经使用顺序表的修改时候,我们需要写两个函数,一个用于得到某个下标的值,一个用于修改顺序表的值。才能解决这个问题。

而现在我们只需要一个函数即可

#include<iostream>
#include<assert.h>
using namespace std;
struct SeqList
{
  int a[100];
  int size;
};
int& SLAt(struct SeqList* ps, int pos)
{
  assert(ps);
  return ps->a[pos];
}
int main()
{
  struct SeqList s;
  SLAt(&s, 1) = 1;
  cout << SLAt(&s, 1) << endl;
  SLAt(&s, 1) += 5;
  cout << SLAt(&s, 1) << endl;
  return 0;
}

这个函数综合运用了引用作为返回值,既可以作为左值,又可以作为右值的特性。

这也就是引用作为返回值的第二大好处,可以读写返回值

6.引用和指针的区别

语法概念上

引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。

而指针开空间,存储一个地址

底层汇编指令来看

引用实际是有空间的,因为引用是按照指针方式来实现的。

引用和指针的不同点:

1. 引用概念上定义一个变量的别名,指针存储一个变量地址。

2. 引用在定义时必须初始化,指针没有要求

3. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体

4. 没有NULL引用,但有NULL指针

5. sizeof中含义不同引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)

6. 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小

7. 有多级指针,但是没有多级引用

8. 访问实体方式不同,指针需要显式解引用,引用编译器自己处理

9. 引用比指针使用起来相对更安全

七、内联函数

1.概念

inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。

2.使用

当我们在使用函数的时候,会开辟栈帧,产生消耗。并且如果这个函数需要大量重复的使用,并且比较短的时候,我们可以考虑使用内联函数

#include<iostream>
using namespace std;
inline int Add(int x, int y)
{
  return x + y;
}
int main()
{
  for (int i = 0; i < 100000; i++)
  {
    cout << Add(i, i + 1) << endl;
  }
  return 0;
}

如果是在C语言中,那么我们只能使用宏,但是宏需要注意的事项太多了

宏的优点是:不需要建立栈帧、提高调用效率

缺点是:复杂,容易出错,可读性差,不能调试

而内联函数他不复杂,不容易出错,可读性不错,可以调试

但是内联函数只适合短小的频繁调用的函数,如果函数太长,会造成代码膨胀

而且inline对于编译器仅仅只是一个建议,最终是否会称为inline,编译器自己决定

像类似的函数加上了inline也会被否决掉

1.比较长的函数

2.递归函数

还需要注意的是:debug环境下,inline不起作用,否则不方便调试了

但是在release下又不方便看汇编了

所以我们可以这样做

然后我们就可以进入调试反汇编查看了,下面就是内联函数的调用,只要我们没有观察到call Add就代表着是内联了

当然,我们也可以使得我们的函数变得很大,以至于他不是内联了

3.内联函数的注意事项

需要注意的是:

inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。

使用inline,编译器会以为内联函数使用的地方都会展开,是不会生成地址的,所以没有生成符号表。就找不到定义。

所以内联函数的定义和声明一定要在一起

八、auto(c++11)

auto他可以自动计算类型

如下代码所示

int main()
{
  int a = 10;
  auto b = a;
  auto c = 1 + 1.1;
    auto* e = &a;//指定右边必须是指针,否则报错
    auto& f = c
}

atuo一般适合未来一些类型特别长的代码

需要注意的是:

1.当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量

2.auto不能作为函数的参数

3.auto不能直接用来声明数组

九、基于范围的for循环(C++11)

如下代码所示,意思是,将arr中的每一个元素的值依次赋给e。

这种for适用于任何数组,依次取数组中的每个值赋给e,自动迭代,自动判断结束

int main()
{
  int arr[] = { 0,1,2,3,4,5,6 };
  for (auto e : arr)
  {
    cout << e << ' ';
  }
  cout << endl;
  return 0;
}

需要注意的是:像下面这种方式是无法修改数组的值的,因为e的改变不会影响arr的改变

我们可以对e加上一个引用就可以改变了

int main()
{
  int arr[] = { 0,1,2,3,4,5,6 };
  for (auto e : arr)
  {
    cout << e << ' ';
  }
  cout << endl;
  for (auto& e : arr)
  {
    e *= 2;
  }
  cout << endl;
  for (auto e : arr)
  {
    cout << e << ' ';
  }
  cout << endl;
  return 0;
}

需要注意的是:对于下面的代码是错误的,因为array其实是一个指针,而不是数组

十、指针空值nullptr(C++11)

我们看下面这段程序,我们发现对于NULL指针直接调用的时候,居然调用的是f(int)

对于这个现象,在早期的c++的库里是这样写的

可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦

如前面的代码,程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的初衷相悖。

C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void *)0

为了解决上面的现象,c++多了一个nullptr,也代表空指针,但是他优化了前面的缺陷


好了,本节内容就到这里了

如果对你又帮助的话,不要忘记点赞加收藏哦!!!

相关文章
|
6月前
|
存储 编译器 程序员
【C++初阶】第一站:C++入门基础(下)-2
【C++初阶】第一站:C++入门基础(下)-2
|
6月前
|
编译器 C++
【C++初阶】第一站:C++入门基础(下)-1
【C++初阶】第一站:C++入门基础(下)-1
|
6月前
|
C语言 C++
【C++初阶】第一站:C++入门基础(中)-2
【C++初阶】第一站:C++入门基础(中)-2
|
6月前
|
编译器 Linux C语言
【C++初阶】第一站:C++入门基础(中)-1
【C++初阶】第一站:C++入门基础(中)-1
|
6月前
|
编译器 C语言 C++
【C++初阶】第一站:C++入门基础(上) -- 良心详解-2
【C++初阶】第一站:C++入门基础(上) -- 良心详解-2
|
6月前
|
安全 Unix 编译器
【C++初阶】第一站:C++入门基础(上) -- 良心详解-1
【C++初阶】第一站:C++入门基础(上) -- 良心详解-1
|
自然语言处理 编译器 Linux
【C++从0到王者】第一站:从C到C++(中)
【C++从0到王者】第一站:从C到C++(上)
54 0
|
编译器 C语言 C++
【C++从0到王者】第一站:从C到C++(上)
【C++从0到王者】第一站:从C到C++
68 0
|
6天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
29 5
|
12天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
40 4
下一篇
无影云桌面