当前位置: 首页 > news >正文

【C++】入门基础知识(下)

文章目录

  • 上文链接
  • 一、引用
    • 1. 概念和语法
    • 2. 引用的特性
    • 3. 引用的使用场景
      • (1) 场景一:引用传参Ⅰ
      • (2) 场景二:引用传参Ⅱ
      • (3) 场景三:引用返回值
      • (4) 总结
    • 4. 引用与指针
    • 5. const 引用
      • (1) const 引用的定义
      • (2) const 引用的使用
  • 二、inline
    • 1. 宏函数(易错)
    • 2. inline 关键字
      • (1) inline 关键字的定义
      • (2) inline 的设计意义
        • 前置知识
        • 设计意义
      • (3) inline 的注意事项
        • 全局变量 / 函数的危害
        • 如何解决这个问题
  • 三、nullptr

上文链接

【C++】入门基础知识(上)


一、引用

1. 概念和语法

引用就是给已存在的变量取了一个别名,不是新定义的一个变量。编译器不会为引用变量开辟新的内存空间,它和它引用的变量公用同一块内存空间

比如在水浒传中的李逵,宋江叫 “铁牛”,江湖上人称 “黑旋风”;林冲,外号豹子头

语法类型& 引用别名 = 引用对象

#include <iostream>using namespace std;int main()
{int a = 1;// 引用int& b = a; // 给 a 取别名叫 bint& c = a; // 给 a 再取别名叫 cint& d = b; // 给别名 b 取别名叫 d// 地址都是相同的cout << &a << endl;cout << &b << endl;cout << &c << endl;cout << &d << endl;++d; // 给 d 加一之后 a,b,c,d 实际上都加了一cout << a << ' ' << b << ' ' << c << ' ' << d << endl;return 0;
}

请添加图片描述

2. 引用的特性

  • 引用在定义时必须先初始化
int a = 1;
int b& = a; // OKint &c;
c = a // ERROR

  • 一个变量可以有多个别名
int a = 1;
int b& = a;
int c& = a;
int d& = a; // OK

  • 引用一旦引用一个实体,就不能引用其他实体
#include <iostream>using namespace std;int main()
{int a = 1;int& b = a;int c = 2;b = c; // 这里不是让 b 引用 c,而是一个赋值cout << &a << endl;cout << &b << endl;cout << &c << endl;return 0;
}

请添加图片描述

由此可见,C++ 中引用的指向是不能改变的

3. 引用的使用场景

(1) 场景一:引用传参Ⅰ

我们在以前写交换两个变量的值的程序时大概会有这样的写法

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

请添加图片描述

由于函数的形参只是实参的拷贝,单纯地传入 a 和 b 的值是不能够交换两个变量的值的,于是我们借用了指针来实现这一个交换两个变量值的函数。

现在我们呢有了引用,于是我们可以借用引用的特性,来实现这个函数

#include <iostream>using namespace std;void Swap(int& a1, int& b1)
{int tmp = a1;a1 = b1;b1 = tmp;
}int main()
{int a = 1;int b = 2;Swap(a, b);cout << a << ' ' << b << endl;return 0;
}

请添加图片描述

由于引用是给变量取了个别名,所以传过去的 a 和 b 在函数体中改变的是它们的别名,等同于改变它们本身


(2) 场景二:引用传参Ⅱ

当我们在用 C 语言实现单链表的尾插操作的时候,我们可能会用到二级指针,这里对于初学者来说不太容易理解。由于当链表为空的时候,我们需要将指向链表的指针指向新的节点,这里相当于是改变了一个指针的值(注意不是改变指针所指向的内容的值),于是我们需要一个二级指针去改变它。

typedef int SLTDataType; typedef struct SListNode 
{SLTDataType data;  struct SListNode* next; 
} SListNode;SListNode* CreatSListNode(SLTDataType x) 
{SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));  // 开辟一块新空间newnode->data = x;  // 初始化数据newnode->next = NULL;return newnode;
}void SListPushBack(SListNode** pphead, SLTDataType x) 
{SListNode* newnode = CreatSListNode(x);  // 创建新节点if (*pphead == NULL) {*pphead = newnode;  // 修改了一级指针的地址,所以用二级指针}else {// ...}
}int main()
{SListNode* plist = NULL;SListPushBack(&plist, 1);SListPushBack(&plist, 2);SListPushBack(&plist, 3);return 0;
}

现在我们可以利用引用的特性,对上面的函数进行修改,使该函数理解起来更加的容易

void SListPushBack(SListNode*& phead, SLTDataType x)  // 给指针取了一个别名叫 phead
{SListNode* newnode = CreatSListNode(x);if (phead == NULL){phead = newnode;  // 只需修改这个别名,即可修改外面的 plist}else{// ...}
}int main()
{SListNode* plist = NULL;SListPushBack(plist, 1);SListPushBack(plist, 2);SListPushBack(plist, 3);return 0;
}

现在我们给 plist 指针取了一个别名phead,之前我们要考虑当链表为空时用二级指针去改变一级指针,现在我们只需要直接改变这个别名 phead 就能做到修改原来的指针的操作,在操作和理解上都更加的容易,非常方便


(3) 场景三:引用返回值

当我们想对栈顶元素进行操作的时候,比如 +1 操作,思考下面方法是否可行(假设栈中已有元素)

#include<assert.h>typedef int STDataType;
typedef struct Stack 
{STDataType* a;  // 用顺序表来实现int top;  // 指向栈顶int capacity;  // 栈的容量
}ST;STDataType StackTop(ST* ps) 
{assert(ps);assert(ps->top > 0);return ps->a[ps->top - 1];
}int main()
{ST st;StackTop(&st) += 1;return 0;
}

这样是不可行的,编译器会报一个这样的错误

请添加图片描述

因为当我们的函数返回的是一个值得时候,实际上是创建了一个临时对象,它是返回值的一个拷贝。之后再将这个临时对象传给调用函数的地方。而这个临时对象是一个右值,具有常性,是不可以直接修改的

那么这个时候我们就可以将这个函数的返回值类型进行修改,修改为一个返回一个引用,这个时候返回得就是返回对象的一个别名,这时它是可修改的。

STDataType& StackTop(ST* ps) 
{assert(ps);assert(ps->top > 0);return ps->a[ps->top - 1];
}int main()
{ST st;StackTop(&st) += 1;return 0;
}

请添加图片描述

所谓临时对象就是编译器需要一个空间暂存表达式的求值结果时临时创建的一个未命名的对象,C++ 中把这个未命名对象叫做临时对象

常见的会创建临时对象的场景有以下三种

  1. 函数传值返回
  2. 表达式运算,如 a + b
  3. 类型转换

有些场景下是不能够返回引用的,我们来看这样一个例子

int& Add(int x, int y)
{   int ret = x + y;return ret;
}int main()
{int a = 1;int b = 2;Add(a, b) += 1;return 0;
}

这样写虽然不会直接报错,但是会有一个警告

请添加图片描述

为什么会这样?因为我们在调用函数的时候,会建立一个函数栈帧,相当于开了一个空间。这个空间内存放了函数内部的变量等,函数调用结束时,这个函数栈帧就会被销毁,相当于这里的 ret 没了,而你的返回值又是一个引用,是一个别名,现在这个别名的原主人都不在了,就出现了类似 "野引用” 的概念,所以就会出问题

那为什么这里不会报错呢?因为这里所谓的栈帧被销毁了指的是这块空间的使用权不再归你所有,而是还给了操作系统。而引用的底层其实是指针(这部分内容不作为本篇重点,了解即可),所以我还是可以把结果返回过来,不会报错,但是会有警告

那我们来对比一下引用作为返回值时的这两个例子,为什么第一种可以,而第二种却不行

StackTop(&st) += 1StackTop 函数结束的时候,栈帧销毁,但是返回对象不在 StackTop 这个栈帧中,而是在外面定义的结构体中,所以返回它的引用没问题

Add(a, b) += 1Add 函数结束的时候,栈帧销毁,返回对象 ret 在栈帧当中,也被销毁,这个时候返回它的引用就会出问题

所以当函数调用结束时,返回对象还在,我们就可以使用引用返回。反之不行,但是程序不一定报错


(4) 总结

  • 引用在实践中只要是用于引用传参引用做返回值减少拷贝提高效率改变引用对象时同时改变被引用对象
  • 引用传参和指针传参功能是类似的,引用传参相对更方便一些
  • 引用作为返回值的场景较为复杂,上面只是展示了一种,更深入的内容这里不展开讨论

4. 引用与指针

从上面的场景来看,我们发现引用和指针有着很多相似的地方,可以说,引用和指针是相辅相成的。因为引用本身就是在指针的基础之上被创造出来的。它们两个就像孪生兄弟一样,指针是哥哥,引用是弟弟。它们各自有各自的作用。

  • 语法概念上引用时给一个变量取一个别名,不开空间;指针时存储一个变量的地址,要开空间
  • 引用在定义时必须初始化;指针建议初始化,但不是必须的
  • 引用可以直接访问指向对象,指针需要解引用才能访问指向对象
  • sizeof 中含义不同,引用结果为引用类型的大小;而指针始终是内存地址空间所占字节个数(32 位平台下占 4 个字节,64 位下是 8 byte)
  • 引用很少出现 “野引用” 的概念,相对更安全;而指针很容易出现空指针和野指针的问题
  • 引用在初始化时引用一个对象后,不能改变引用对象;而指针可以不断改变指向对象

很多时候能用指针的地方,我们可以用引用来代替,并且理解起来也更加方便。那么是不是所有用指针的时候我们都可以使用引用来代替呢?答案是否定的。

我们来看这样一个例子

typedef int SLTDataType;typedef struct SListNode
{SLTDataType data;struct SListNode* next;
} SListNode;

我们在定义链表的时候,我们使用了指针来指向下一个节点,那么我们可不可以用引用来替代此处的指针

struct SListNode& next;

不行!,因为在之前我们提到过在 C++ 中引用是不可以改变指向的,所以当我们在删除链表节点的时候,我们需要让被删除节点的前一个节点的指针指向删除节点的后一个节点,但是对于引用来说是做不到的,因为这样改变了引用的指向

并且指针可以有空指针,但是引用不能有空引用


5. const 引用

(1) const 引用的定义

我们先来看看下面这段代码

int main()
{const int a = 1;int& ra = a;return 0;
}

请添加图片描述

这段代码是有问题的,因为你原来的变量 a 是 const int,但是别名却变成了 int&,这里的引用实际上是对 a 访问权限的放大。意思就是原本 a 是不可修改的,但你取了别名变成 int 那就可以修改了,这就矛盾了

那如果我非要对这里的 a 进行一个引用,那么必须变成一个 const 引用

int main()
{const int a = 1;const int& ra = a;  // OKreturn 0;
}

那么如果原本的变量不是 const 类型,引用可不可以变成 const 引用呢?答案是肯定的

int main()
{int a = 1;const int& ra = a;  // OKreturn 0;
}

这是因为在引用的过程当中,权限可以缩小,但是不能放大。并且缩小权限指的是引用的权限,原本的值还是可以进行修改的,只不过 const 的引用不能修改了

int main()
{// 放大权限 --> ERRORconst int a = 0;int& ra = a;// 缩小权限 --> OKint b = 1;const int& rb = b;b++; // OKrb++; // ERRORreturn 0;
}

const 引用还可以引用字面量

const int& rc = 30; // OK

(2) const 引用的使用

  • 函数传参

const 引用可以用在函数的传参当中,用于缩小权限。在 C++ 的 STL 库中就有函数的形参采用的是 const 引用

请添加图片描述


  • 类型转换

在 C 语言中我们就知道,一个 double 类型是可以转换成 int 类型的。

double a = 0.1;
int b = a;  // OK
std::cout << b << std::endl;  // 0

int b = a; 这一个语句中,将 double 类型转换成了 int 类型,它的本质是在转换的过程中创建了一个临时对象,并且这个临时对象的类型是 int,之后把 double 类型的整数部分去处理放在这个临时对象中,再将临时对象的值给 b

请添加图片描述

那么我们是否可以把一个 double 类型取一个 int 类型的引用呢

double a = 0.1;
int& b = a; // ?

在上文的学习中我们知道,临时对象具有常性,是不可修改的。所以在这里相当于你给一个不可修改的值取了一个 int 类型的引用,是扩大了权限,是不可以的,所以不能这样写

所以为了不扩大权限,我们可以采用 const 引用为其取引用

double a = 0.1;
int& b = a; // ERROR
const int& b = a; // OK

注意权限放大缩小只涉及指针和引用,而普通的变量是不涉及这个概念的

  • 指针
const int a = 0;
int* pa = &a; // ERROR

*上面的写法是错误的,同样属于权限扩大。因为 &a 的类型原本是 const int,所以不能定义为 int* **。

  • 普通变量
const int a = 0;
int b = a; // OK

这种写法是正确的,因为这里只是把 a 的值用来初始化 b,并不涉及权限的问题,两个变量之间互不影响。


二、inline

1. 宏函数(易错)

  • Q:请写出一个实现两数相加的宏函数

常见的错误写法有以下几种

// #define Add(int a, int b) return a + b
// #define Add(a, b) (a) + (b)
// #define Add(a, b) (a + b)
// #define Add(a, b) ((a) + (b));

正确的写法应该是

#define Add(a, b) ((a) + (b))

  • 为什么不能加分号

宏函数不是函数,它是一种替换

我们希望的是写 Add(1, 2) 的时候自动替换成 ((1) + (2)) 而不是 ((1) + (2));

if (Add(1, 2)) ---> if( ((1) + (2)) ) // OK
{// ...
}if (Add(1, 2)) ---> if( ((1) + (2)); ) // ERROR

  • 为什么要加外面一层的括号

很简单,因为优先级的问题。

cout << Add(1, 2) * 5 << endl; // 期望值: 15
// ((1) + (2)) * 5 是对的,值为 15
// (1) + (2) * 5 是错的,值为 11

  • 为什么要加内层的括号

这时因为 a 和 b 除了可以是值,还可以是表达式

int x = 1, y = 2;
Add(x & y, x | y);
// ((x & y) + (x | y)) 是对的
// (x & y + x | y) 是错的,因为这样它会先计算加法

宏函数再预处理时可以替换展开,没有函数调用建立栈帧等消耗,效率变高。但是从上面的例子来看,宏函数非常容易出错,且不方便调试


2. inline 关键字

(1) inline 关键字的定义

于是在宏函数的基础上, C++ 就创造出了 inline 关键字,用 inline 修饰的函数叫做内联函数,编译时 C++ 编译器会在调用的地方展开内联函数,这样一来就可以像宏函数一样不用建立函数函数栈帧了,提高了效率。它和宏函数的功能几乎一样,可以替代宏函数,并且不像宏函数那样易错

inline 对于编译器而言只是一个建议,也就是说,加了 inline 之后编译器可以选择在调用的地方不展开,不同的编译器关于 inline 什么情况下展不展开各不相同,因为 C++ 标准并没有规定这一点。inline 适用于频繁调用的短小函数,对于递归函数,代码相对多一些的函数,加上 inline 也会被编译器忽略

也就是说这个函数到底展不展开不由我自己决定,这样设计的意义何在?我们来看几个例子


(2) inline 的设计意义

  • 前置知识

在阐述 inline 关键字的意义之前,我们需要了解一些前置知识

#include<iostream>inline int Add(int x, int y)
{int ret = x + y;return ret;
}int main()
{int a = 1, b = 2;std::cout << Add(1, 2) << std::endl;return 0;
}

当一个函数编译好之后,实际上是一串指令,可以通过汇编来查看,比如下面这个样子

请添加图片描述

比如这里的前三句指令就是一个建立函数栈帧的过程

请添加图片描述

而函数的地址就是第一句指令的地址,即这里的 00B31830

调用函数其实就是 call 函数的地址,跳转过去执行指令

请添加图片描述


  • 设计意义

假如说一个函数编译好之后有 10 行指令,现在假设我要调用这个函数 10000 次

那么如果这个时候我用 inline 关键字将这个函数进行展开,就会有 10000 * 10 行指令。因为这个时候就相当于是函数的每一处都对应了 10 行指令,一共有 10000 处

但是如果我不展开的话,只会有 10000 + 10 行指令。因为这时候就相当于 10000 个 call,再加上这个函数的 10 行指令

所以说 inline 的一个很大的问题是一定程度上让编译后的可执行程序变大,这会占用内存,是一件不好的事情。因此,如果一个内联函数任由程序员决定它是否展开的话,可能会导致这个程序变得很大。所以在设计的时候编译器不选择相信程序员而是选择相信自己

注:vs编译器的 debug 版本下面默认是不展开 inline 的,这样方便调试


(3) inline 的注意事项

inline 不建议声明和定义分离到两个文件,分离可能会导致链接错误

为什么?我们先来看看这样一个问题


  • 全局变量 / 函数的危害

假如现在我在做一个项目,我需要用到一些公共的函数,比如说交换函数,我把它放在一个头文件 Common.h 里,作为一个全局函数

// Common.h
void Swap(int* a, int* b)
{int tmp = *a;*a = *b;*b = tmp;
}

现在我在同一个项目中的两个文件中都包含这个 Common.h,这个时候就会发生重定义的问题

// test_1.cpp
#include"Common.h"// test_2.cpp
#include"Common.h"

请添加图片描述

为什么会冲突?这时因为这几个文件在翻译环境下的链接阶段时,会有一个符号表合并的过程。而只有具有外部链接属性的变量 / 函数才会放进符号表,比如这里你包含了 Common.h 这个头文件,那么头文件一展开,Swap 函数都放进了 test_1.objtest_2.obj 这两个文件对应的符号表中,但是一合并之后发现,你有 Swap 函数,我也有 Swap 函数,这样一来就发生冲突了

注:该部分知识不属于本篇重点,应该在学习 C 语言的时候学习过了,这里就不细讲了


  • 如何解决这个问题

解决方案一static

我们可以在 Swap 函数前加上一个 static,即把外部链接属性变成内部链接属性

// Common.h
static void Swap(int* a, int* b)
{int tmp = *a;*a = *b;*b = tmp;
}

这样的话在链接的时候 Swap 函数就不会进入 test_1.objtest_2.obj 两个文件对应的符号表,就不会出现冲突的问题


解决方案二声明与定义分离

即我们可以新创建一个 Common.cpp 文件,仅把函数的声明放在 Common.h 中,把具体函数的定义放在 Common.cpp 中,这样也不会发生冲突

// Common.h
void Swap(int* a, int* b);// Common.cpp
void Swap(int* a, int* b)
{int tmp = *a;*a = *b;*b = tmp;
}

这是因为虽然你包含了 Common.h 这个头文件,但是它里面只有函数 Swap 的声明,没有定义,而只有有定义的函数才会进入到符号表,所以这个时候 Swap 进入到的是 Common.obj 的符号表,没有其他符号表与它冲突,所以就不会报错了


那么为什么内联函数不建议声明和定义分离到两个文件中呢?因为内联函数不会放进符号表!为什么不会?因为编译器认为内联函数已经在函数调用的时候已经展开了,进而编译器就认为它不用放进符号表了,因为编译器觉得没有别人会 call 它的地址。所以如果分离之后就会导致在符号表合并的时候找不到调用内联函数的地方,找不到对应的内联函数的定义,因为它根本就不在符号表中,就会报错

因此,内联函数定义的时候直接定义在 .h 文件中即可


三、nullptr

在 C 语言中,原本的 NULL 实际上是一个,在传统的 C 头文件(stddef.h)中,可以看到以下代码

#ifndef NULL#ifdef __cplusplus#define NULL 0#else#define NULL ((void *)0)#endif
#endif

在 C++ 中 NULL 可能被定义为字面常量 0,或者在 C 语言中被定义为无类型指针(void*)的常量,但是两种定义都会有一定的缺陷,我们来看这样一个例子

#include <iostream>using namespace std;void f(int x)
{cout << "f(int x)" << endl;
}void f(int* ptr)
{cout << "f(int* ptr)" << endl;
}int main()
{f(0);f(NULL);f((int*)NULL);
}

请添加图片描述

本来我们希望实现 f(NULL) 的时候是调用的 f(int* ptr) 但是现在由于 NULL 的定义的原因变成了调用 f(int x),导致我们还要取强转类型,就很别扭

于是在 C++11 中引入了 nullptr,它是一种特殊类型的字面量,它可以 转换成任意类型的指针类型。所以使用 nullptr 定义空指针可以避免类型转换的问题

因此我们以后初始化空指针都使用nullptr

http://www.xdnf.cn/news/70057.html

相关文章:

  • JAVA实战开源项目:医院资源管理系统 (Vue+SpringBoot) 附源码
  • leetcode day 35 01背包问题 416+1049
  • buildadmin 自定义单元格渲染
  • 【STM32单片机】#10.5 串口数据包
  • 在线打开查看cad免费工具dwg, dxf格式工具网站
  • 14.电容的高频特性在EMC设计中的应用
  • Novartis诺华制药社招入职综合能力测评真题SHL题库考什么?
  • 抱佛脚之学SSM三
  • Anaconda Prompt 切换工作路径的方法
  • RNA Club | CRISPR-Cas 免疫系统的作用原理及其与噬菌体的对抗-王艳丽教授讲座笔记
  • Activity之间交互
  • unity动态骨骼架设+常用参数分享(包含部分穿模解决方案)
  • 22. git show
  • MyBatis-Plus 实战:优雅处理 JSON 字段映射(以 JSONArray 为例)
  • 12个领域近120个典型案例:2024年“数据要素X”大赛典型案例集(附下载)
  • 网络编程4
  • L1-106 偷感好重 - java
  • vision transformer图像分类模型结构介绍
  • 运维:概念、模式与硬件基础
  • 【MySQL】详细介绍(两万字)
  • 反射内存网技术应用于数控系统
  • Shell脚本-四则运算符号
  • 软件测试入门知识详解
  • 使用Unity Cache Server提高效率
  • 二分查找、分块查找、冒泡排序、选择排序、插入排序、快速排序
  • Maven编译打包
  • MySQL的ACID特性
  • 抽象类的特点
  • 面经-浏览器/网络/HTML/CSS
  • 单页面应用的特点,什么是路由,VueRouter的下载,安装和使用,路由的封装抽离,声明式导航的介绍和使用