【C++】C++入门必备知识详细讲解

简介: 【C++】C++入门必备知识详细讲解

首先我们先要知道,C++是在C的基础之上,容纳进去了面向对象编程思想,并增加了许多有用的库。本章将会带大家了解,C++是补充C语言语法的不足,以及C++是如何对C语言设计不合理的地方进行优化的。

一、命名空间

1. namespace

在C/C++中,变量、函数等等都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突,namespace 关键字的出现就是针对这种问题的。

例如,我们想定义一个变量 sqrt ,直接定义在全局变量然后编译是可以通过的,例如下图:

但是,我们知道 sqrt 其实是一个库函数,它包含在 math.h 的头文件中,假设我们加上 math.h 的头文件,还能编译过吗?答案是不能,因为它们重名了,如果包含了 math.h 的头文件,编译不会通过,会报下图中的错误:

那么有没有好的解决方案呢,答案是有的,C++中就增加了 namespace 这样的关键字解决这样的问题。例如我们可以将我们需要定义的变量放入 namespace 的命名空间中,然后在使用让编译器在指定的命名空间中寻找;如果不指定编译器,编译器优先会在全局域中寻找变量;namespace 的使用:

#include <stdio.h>
    #include <math.h>
    // 命名空间的名字
    namespace Young
    {
      int sqrt = 10;
    }
    int main()
    {
      printf("%d\n", Young::sqrt);
      return 0;
    }

上述代码的使用就是让编译器在指定的命名空间 Young 中去寻找变量 sqrt 然后使用这个变量,这样就不会与库函数中的 sqrt 函数有命名冲突了;Young 是一个可以自己命名的命名空间的名字,可以取任意名字,不一定是 Young.

printf("%d\n", Young::sqrt);中,sqrt 前面的 :: 符号,叫做域作用限定符,意思是让编译器使用域作用限定符前面的命名空间中定义的东西。

2. namespace 的使用场景

除了上面我们使用 namespace 在命名空间中定义变量外,还可以定义函数、结构体等;除此之外,还可以嵌套使用。例如以下代码:

namespace Young
    {
      //变量
      int sqrt = 10;
      // 函数
      int Add(int a, int b)
      {
        return a + b;
      }
      // 结构体
      struct ListNode
      {
        int data;
        struct ListNode* next;
      };
      // 嵌套使用 
      namespace Y
      {
        int a = 10;
      }
    }
    int main()
    {
      int ret = Young::Add(1, 2);
      printf("%d\n", ret);
      struct Young::ListNode node;
      printf("%d\n", Young::Y::a);
      return 0;
    }

上述代码中主函数部分,结构体中的域作用限定符是要在 ListNode 前使用,而不是在 struct 前使用;嵌套使用 namespace 是从右往左看,到指定的命名空间中去寻找;

虽然这种方法可以有效避免命名冲突问题,但是每次用的时候都要在前面加上域作用限定符,是不是很麻烦呢?确实是,但是还有一种方法可以解决,将命名空间展开;以上面的命名空间为例,例如以下代码:

// 将命名空间展开
    using namespace Young;
    using namespace Y;
    int main()
    {
      int ret = Add(1, 2);
      printf("%d\n", ret);
      struct ListNode node;
      printf("%d\n",a);
      return 0;
    }

上面的代码就将 Young 和 Y 两个命名空间中的内容展开,就不用再使用域作用限定符了;除此之外,我们还可以展开部分命名空间中的内容,例如,我只展开 Add 函数出来:

// 展开部分
    using Young::Add;
    int main()
    {
      int ret = Add(1, 2);
      printf("%d\n", ret);
      struct Young::ListNode node;
      printf("%d\n", Young::Y::a);
      return 0;
    }

以上就是展开部分的命名空间,通常在做项目的时候,我们都不会将命名空间展开,因为展开就会变得不安全;但是在平常我们在写代码练习的时候,可以将命名空间展开,更有利于我们练习。

二、了解 C++ 中的输入和输出

首先我们先要知道,C++中引入了不同于C语言的输入和输出,在C语言中我们使用 scanf 和 printf 作为输入和输出,但是在C++中了 cout 标准输出对象(控制台)和 cin 标准输入对象(键盘);我们先看看它们的使用:

我们可以了解到,上述代码中的 cout 和 cin 分别叫做流插入运算符流提取运算符,关于这两个更多的我们在以后的学习中再介绍;其中 cout 和 cin 必须包含< iostream >头文件以及按命名空间使用方法使用 std ,其中 std 是C++标准库的命名空间名,C++将标准库的定义实现都放到这个命名空间中。所以我们可以展开 std 的命名空间:

#include <iostream>
    using namespace std;
    int main()
    {
      int input;
      double d;
      // 自动识别类型
      cin >> input >> d;
      cout << input << endl << d << endl;
      return 0;
    }

除此之外,cin 和 cout 还可以自动识别变量的类型,如上述代码,它的输出如下图:

三、缺省参数

缺省参数是声明或定义函数时为函数的参数指定一个缺省值。在调用该函数时,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参。先看看缺省参数的使用:

在上面的使用中,Add 函数就是用了缺省参数,在 Add 函数定义中,它指定了 a = 100,b = 200,意思就是,当调用 Add 函数时,如果没有参数传进来,就使用它自己定义的变量;传参时,就使用指定的实参,如下图:

当然也可以只传一部分参数,但是当出现多个参数时,参数必须从右往左依次来给出,不能间隔着给;例如:

#include <iostream>
    using namespace std;
    int Add(int a = 100, int b = 200, int c = 300)
    {
      return a + b + c;
    }
    int main()
    {
      int a = 10, b = 20, c = 30;
      int ret = Add(a);
      cout << ret << endl;
      return 0;
    }

以上这段的代码输出结果就是 510 ,那么例如 int ret = Add(a,,c); 这种传参是不允许的。

那么我们可以给缺省参数分类,像上面代码中,Add()这种什么都不传的就叫做全缺省参数;像Add(a)或者Add(a,b)这种只传一部分的就叫做半缺省参数

最后,我们要注意缺省参数不能在函数声明定义中同时出现,如果在函数声明和函数中同时出现,我们只需要在声明中给缺省值即可。

四、函数重载

1. 函数重载的概念

函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。我们先看使用:

#include <iostream>
    using namespace std;
    void Add(int a ,double b)
    {
      // 打印数据方便观察
      cout << "void Add(int a ,double b)" << endl;
    }
    void Add(double a, int b)
    {
      // 打印数据方便观察
      cout << "void Add(double a, int b)" << endl;
    }
    int main()
    {
      Add(3, 3.14);
      Add(3.14, 3);
      return 0;
    }

运行的结果如下:

以上代码中,我们在函数中打印数据,是为了说明编译器调用了这个函数;我们定义了两个同名的函数,但是它们的参数类型不一样,而我们在使用这两个函数的时候,传的参数也不一样,所以它们会调用各自对应的函数;

2. C++支持函数重载的原理

C++支持函数重载的原理是因为C++有自己的函数名修饰规则

我们知道,.cpp文件或者.c文件在生成可执行程序之前,要经过预处理,编译,汇编,链接的过程,具体回顾往期博客:预处理和程序环境

其中,C语言在编译过程中,符号汇总将所有.c文件的函数名汇总在一起,注意,是函数名,所以在C语言中,重名的函数名在编译过程中会有冲突,编译不通过;

但是,在C++中的函数名修饰规则中,C++不是用函数名汇总在一起,而是有它自己的修饰规则,具体的修饰规则在不同的编译器有不同的修饰规则,例如:

void func(int i, double d)
    {}
    void func(double d, int i)
    {}

这两个函数,在 g++ 编译器的函数修饰后变成【_Z+函数长度+函数名+类型首字母】,如图:

所以它们在编译汇总的时候是可以区分开来的。

五、引用

1. 引用的概念

引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。先看一个简单的例子:

#include <iostream>
    using namespace std;
    int main()
    {
      int a = 10;
      int& b = a;
      return 0;
    }

以上代码中int& b = a;就是在定义引用类型,b 就是 a 的别名,a 和 b 实际上都是指向同一个空间,a 的改变会影响 b ,b 的改变也会影响 a.

2. 引用特性

  1. 引用在定义时必须初始化
  2. 一个变量可以有多个引用
  3. 引用一旦引用一个实体,再不能引用其他实体
void Test()
  {
    int a = 10;
    // int& ra;   // 该语句编译时会出错
    int& ra = a;
    int& rra = a;
  }

int& ra; 会编译出错是因为在定义时没有初始化;上述代码中,rra 是 ra 的别名,也是 a 的别名,这三个变量用的都是同一个空间,它们之间的互相改变都会影响彼此。

3. 常引用

我们在使用引用时要遵守一条规则,就是在引用的过程中,权限可以平移,权限也可以缩小,但是权限不能放大。例如:

int main()
    {
      const int a = 0;
      // 权限的放大,不允许
      //int& b = a;
      // 不算权限的放大,因为这里是赋值拷贝,b修改不影响a
      //int b = a; 
      // 权限的平移,允许
      const int& c = a;
      // 权限的缩小,允许
      int x = 0;
      const int& y = x;
      return 0;
    }

上述代码中,权限的放大是指,const int a = 0;const修饰的 a 变量具有常性,不可修改,是只读,但是int& b = a;代表 b 的值可修改,并且 b 的值修改会影响 a ,b 是可读可写的,但是 a 只有只读,所以这里是权限的放大;但是int b = a; 不算权限的放大,因为这里是赋值拷贝,b 的修改不影响 a.

权限的平移是指,大家都具有一样的权限,例如上述代码中的const int& c = a;此处的 c 和 a 都被 const 修饰了,大家都具有常性,所以是权限的平移,是可以的。

权限的缩小在上述代码中,int x = 0; const int& y = x;是指 x 是可读可写的,但 y 被 const 修饰了,只有只读,但是从可读可写转变成只读是允许的,这种就叫做权限的缩小。

那么我们看一下以下的语句属于什么呢?

void test()
    {
      int i = 0;
      double& d = i;
    }

首先我们应该了解清楚,如果是int i = 0; double d = i;也是可以的,因为它们之间会发生整型提升;那么我们要清楚,这个整型提升的过程中,会发生拷贝的过程,d 取的是 i 的临时拷贝,如下图,而这个临时拷贝具有常性,不可被修改,所以这里是权限的放大,是不允许的。

所以正确的语句应该如下:

void test()
    {
      int i = 0;
      const double& d = i;
    }

将 d 的属性也变成不可修改,那么它们之间就是权限的平移关系了。

4. 引用的使用场景

(1)做参数(传引用传参)

我们常见的传引用传参就是交换函数了,写一个我们常用的交换函数如下:

#include <iostream>
    using namespace std;
    void Swap(int* p1, int* p2)
    {
      int tmp = *p1;
      *p1 = *p2;
      *p2 = tmp;
    }
    int main()
    {
      int a = 10, b = 20;
      Swap(&a, &b);
      return 0;
    }

在这个交换函数中,我们需要传 a 的地址和 b 的地址过去,才能改变 a 和 b 的值;在C++中,我们可以使用引用完成同样的交换,代码如下:

void Swap(int& p1, int& p2)
    {
      int tmp = p1;
      p1 = p2;
      p2 = tmp;
    }
    int main()
    {
      int a = 10, b = 20;
      Swap(a, b);
      return 0;
    }

使用了引用后,代码整体看起来就很舒服,不用像指针那样传地址和解引用;同时传引用传参还能提高传参的效率,因为每一次传址或者传值都是一次拷贝,每传一次就要多拷贝一次,效率很低;而引用则不需要拷贝,因为形参是实参的别名,就不用进行拷贝。

除此之外,传引用传参最舒服的地方还是在我们以前学过的单链表中,如往期博客 单链表 中,无论是头插还是尾插等等操作,都需要传二级指针才能改变链表的整体结构,而C++引入了引用之后,就不需要传二级指针了,如下代码:

void SLTPushBack(SLTNode*& phead, SLTDateType x)
    {
        // ...
        if (phead == NULL)
        {
            phead = newnode;
        }
        else
        {
            //...
        }
    }
    int main()
    {
        SLTNode* plist = NULL;
        SLTPushBack(plist, 1);
        SLTPushBack(plist, 2);
        SLTPushBack(plist, 3);
        return 0;
    }

(2)做返回值(传引用返回)

在使用传引用返回时需要注意,不像传引用传参一样,传引用返回如果出了函数作用域对象还在的话才可以用,如果出了函数作用域对象不在就不能用;如以下代码:

int& func()
    {
      int n = 0;
      n = 10;
      return n;
    }
    int main()
    {
      int ret = func();
      return 0;
    }

在这段代码中,函数 func 内定义了一个变量 n,但是它的生命周期只在这个函数内,出了函数作用域它的空间就会被销毁,画图更好地理解:

如上图,func 销毁后,n 随之也会销毁,将空间归还给操作系统,但是在 main 函数中,ret 实际上是相当于访问已经销毁的 n ,这严格来说相当于野指针问题了,也就是越界访问。

但是在不同的编译器中,得出的结果却不一样,在 vs2019 中,是可以得到 n 的值,如下图:

而在 gcc/g++ 的编译器中,却报错了,如下图:

原因是因为,这取决于栈帧销毁之后,编译器是否会对已经销毁的空间初始化,如果对已经销毁的空间进行初始化,而继续对它进行访问,就是越界,像 gcc/g++ 这样的编译器,很明显在空间回收时会对空间进行初始化,所以造成越界;而 vs2019 则没有严格的检查。

拓展:那如果将代码改成如下,还能编译通过吗?

int& func()
    {
      int n = 0;
      n = 10;
      return n;
    }
    int main()
    {
      int& ret = func();
      cout << ret << endl;
      cout << ret << endl;
      return 0;
    }

这里将 ret 的接收改成了引用,也就是说,ret 是返回的 n 的别名,我们看执行结果:

第二次执行是随机值,为什么呢?原因是因为 ret 是 n 的别名,它们公用同一个空间,在执行 cout 语句时,也会发生一系列函数栈帧的创建,所以新的空间会覆盖之前的 func 所在的空间,也就是说,n 的空间被覆盖了,也就是 ret 的空间被覆盖了,所以 n 的值也就变成了随机值;第一次是 10 的原因是原来的空间并没有被覆盖。

所以就引入了另一个话题,如果 n 的空间没有被覆盖,它是不是还是 10 呢?那么我们将代码修改成以下代码:

int& func()
    {
      int a[1000];
      int n = 0;
      n = 10;
      return n;
    }
    int main()
    {
      int& ret = func();
      cout << ret << endl;
      cout << ret << endl;
      return 0;
    }

在 func 函数内,我们增加了一个长度为 1000 的数组,我们先看运行结果:

这个时候又变成了 10 ,这是因为函数的栈帧中空间是向下创建的,所以在 func 函数内,先创建 1000 个空间,然后再为 n 创建空间,n 这个时候的位置是处于下方的;如果 func 销毁后,如果有新的空间覆盖,这要取决于这个空间是否比原来 func 的空间要大,如果这个空间很大,覆盖了 n ,那么 n 就会变成随机值,否则,n 还是原来的值。

那么传引用返回有什么应用场景呢?我们常见的传引用返回可以用作修改返回对象,例如在单链表中,查找函数和修改函数可以合并在一起写,使用传引用返回,这样就既可以查找到想要查找的数据,又能修改想要修改的值。例如以下代码:

int& SLFindOrModify(struct SeqList& ps, int i)
    {
      assert(i < ps.size);
      // ...
      return (ps.a[i]);
    }
    int main()
    {
      // 定义对象
      struct SeqList s;
      // 查找 10 这个数据,并将它修改成 20
      SLFindOrModify(s, 10) = 20;
      return 0;
    }

(3)引用和指针的区别

现在我们都学过指针和引用了,我们可以发现,其实引用和指针很相似,在很多用法上指针可以代替引用,引用也可以代替指针,那么它们之间又有什么区别呢?我们一一分析:

引用和指针的不同点:

  1. 引用概念上定义一个变量的别名,指针存储一个变量地址。
  2. 引用在定义时必须初始化,指针没有要求
  3. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何
    一个同类型实体
  4. 没有NULL引用,但有NULL指针
  5. 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32
    位平台下占4个字节)
  6. 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
  7. 有多级指针,但是没有多级引用
  8. 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
  9. 引用比指针使用起来相对更安全

六、内联函数

1. #define定义宏

我们以前学过 #define定义宏,如往期博客 #define定义宏 中,宏给我们带来很多好处,如针对频繁调用的小函数,不需要建立栈帧,提高了效率;如以下代码:

#define ADD(a,b) ((a)+(b))
    int main()
    {
      int ret = ADD(10, 20);
      cout << ret << endl;
      return 0;
    }

以上的宏定义了两个数的相加,注意,这里宏定义的((a)+(b))不能写成(a+b),因为考虑到运算符优先级问题,如ADD(1 | 2 + 1 & 2)这种表达式,加号优先级更高,会先执行加的操作,再执行 | 和 & ,并不是我们想要的结果。

上面的宏定义在预处理阶段是直接展开替换,所以没有建立栈帧,很好地提高了效率。

但是宏给我们带来好处的同时,必然会带来不便,如使用宏定义会容易出错,就如上面两数相加的宏,少一个括号都不行,所以宏的语法坑很多。

最后总结一下宏的优缺点:

优点:

  1. 没有类型的严格限制。
  2. 没有函数栈帧的建立,提高效率。

缺点:

  1. 不方便调试宏。(因为预编译阶段进行了替换)
  2. 导致代码可读性差。
  3. 没有类型安全的检查 。
  4. 容易出错,语法坑多。

2. 内联函数的概念

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

例如以下的两数相加的内联函数:

inline int Add(int a, int b)
    {
      return a + b;
    }
    int main()
    {
      int ret = Add(10, 20);
      cout << ret << endl;
      return 0;
    }

以上代码中,两数相加的内联函数既没有建立函数栈帧,性能有很好的体现,也没有因为运算符问题需要添加很多括号,所以内联函数是综合了宏和函数的优缺点来设计的。

2. 内联函数的特性

(1) inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。

(2) inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。

也就是说,假设你使用了 inline,编译器也不一定会视这个函数为内联函数,因为如果这个函数的规模很大,代码量大,会造成代码膨胀,所以综合性能方面考虑,我们如果使用内联函数,尽量要简化代码。

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

例如我定义了一个 Test.h 的头文件,里面包含 Add 函数的声明:

inline int Add(int a, int b);

再定义一个 Test.cpp 文件,里面包含 Add 函数的实现:

#include "Test.h"
    int Add(int a, int b)
    {
      return a + b;
    }

然后在 main.cpp 函数中调用 Add 函数:

#include "Test.h"
    int main()
    {
      int ret = Add(10, 20);
      cout << ret << endl;
      return 0;
    }

最后编译出错了,如下图:

这是因为什么呢?原因是因为头文件 #include "Test.h" 会在预处理阶段在 main.cpp 文件中展开,展开之后会有函数 Add 的声明,而 Add 函数前加了内联 inline,编译器会认为它就是一个内联函数,认为它就会直接展开,所以在编译阶段没有给它一个有效的地址,也就没有进入符号表;而在 main 函数中调用了 Add 函数,它在符号表中并没有找到自己对应函数的地址,所以会出现链接错误。

七、auto关键字

在 C++11 中,auto 的含义是,auto 声明的变量必须由编译器在编译时期推导而得。也就是说,auto 是一个根据变量自动推导类型的关键字。

例如:

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

当我们需要遍历一个数组时,通常使用以下方式:

int main()
    {
      int arr[] = { 1,2,3,4,5,6,7,8 };
      for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
      {
        cout << arr[i] << " ";
      }
      return 0;
    }

对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此 C++11 中引入了基于范围的 for 循环。for 循环后的括号由冒号“ :”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。使用范围 for 我们可以结合上面所学的 auto 关键字结合使用,例如以下代码:

如果我们需要改变数组中的值,是否像以下代码那样使用呢?

很明显,答案是不可以的,因为 e 只是数组中的数据的临时拷贝,改变临时拷贝的值不影响数组中原来的值,所以我们要加上引用:

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

加上引用后,e 就是数组中的数据的别名,改变 e 也就是改变数组中的内容。

九、指针空值 nullptr

在早期设计 NULL 空指针时,NULL 实际上就是 0,所以导致有些地方使用 NULL 会造成不明确的函数调用,例如:

在以上代码中,func 构成函数重载,我们期望的 NULL 是调用 void func(int*) 函数,但是它却调用了另外一个,所以这造成了不明确的函数调用。

所以在 C++11 中,引入了 nullptr,它的类型是无类型指针(void*),这很好地避免了以上的情况,例如下图,nullptr 是调用了具有指针类型的函数:

最后,C++ 入门的全部内容已经全部分享完啦,感觉对自己有帮助的小伙伴赶紧点赞收藏吧~感谢支持!

目录
相关文章
|
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
29 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