引言

在我们学习C语言的指针 的时候大多数教材都会讲到“交换a和b两个数 ”的例子:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
void swap(int a,int b){
int temp=a;
a=b;
b=temp;
}
int main(){
int a=1,b=2;
swap(a,b);
printf("%d,%d",a,b);
return 0;
}

由于实参的保护机制,这样的交换是不成功的。要达到交换的目的,C语言告诉我们应该使用指针:


实参的保护机制是C语言的内容,但是为了保证这个C++系列 知识点的完备性,我尽可能地穿插讲解一些C语言的知识点。

知识点补充:实参的保护机制

TODO,涉及到汇编内容比较多,我也不怎么熟悉还得捋捋,但是又得尽可能讲简单点……还没想好怎么写比较好……看来都还给老师了……


1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
void swap(int* a,int* b){
int temp=*a;
*a=*b;
*b=temp;
}
int main(){
int a=1,b=2;
swap(&a,&b);
printf("%d,%d",a,b);
return 0;
}

而C++ 为了避免指针的滥用,引入了比指针更安全的引用类型,来代替指针。

本节我们将细说引用。先来回顾一下指针的基础知识。


指针(pointer)

指针的含义

指针的语法含义就是 指向变量的地址

指针的本质是一个变量,用于存储其他变量的地址。对于32位机器,指针占 4个字节;对于64位机器,指针占8个字节

指针占多少字节还和编译器有关:如果你的电脑是64位的,但是使用的编译器是32位的,指针也只占4个字节。

指针的分类

按照使用的权限,指针可以分为(以整型指针为例):

  1. const int* aint const* a:指向常量的指针,即指针指向的内存是只读的

  2. int* const a:指针常量,即指针只能指向这个地址,不能更改

  3. const int* const a:指向常量的指针常量,是上面两种指针的综合。

有的时候可能会把1和2搞混淆。其实要记住它们也很简单,我们只需要倒过来读就行了。

我们把上面的a读作指针,*读作指向。比如,第一个就可以读作指针指向整型常量 ,即指向常量的指针;第二个读作指针常量指向整型,即常指针。这样就不怕混淆了。

函数指针

如下面的程序,通过打印&foo ,我们可以发现函数也在内存中也占有地址。那么我们就可以将指针指向这个函数,即函数指针

对于游戏开发者,函数指针应该是一个很重要的内容了。我在学习OpenGL时,就经常被函数指针绕晕。这真是无法拒绝,建议多来点/doge

定义函数指针

我们定义一般的指针时,需要指定这个指针的类型。函数指针也同理。

对于一个函数,我们关注的是它的形参类型和返回类型。所以,函数指针的定义方式为:

                                         返回类型(*ptr)(参数列表)

例如:

1
2
3
4
//返回类型为空,参数列表也为空
void (*ptr1)();
//返回类型为整型,参数列表也为两个整型
int (*ptr2)(int,int)

函数指针赋值

定义了一个函数指针后,我们可以将相同类型函数的地址赋给它。

如下图,函数名和函数名取地址都可以代表这个函数的地址。(因为编译器会隐式地将foo转化为&foo)

所以下面两种函数指针的赋值方式都是正确的:

1
2
p=foo;
p=&foo;

使用函数指针

1
2
(*p)();//推荐这种写法
p();//低版本编译器可能报错

定义函数指针类型

之前看ACLLib的头文件时,看到了这样的代码:

1
2
typedef void(*KeyboardEventCallback) (int key, int event);
void registerKeyboardEvent(KeyboardEventCallback callback);

第一句声明了一个函数指针的类型,第二句声明了一个接受函数指针作为参数的回调函数

我们使用typedef自定义一个类型时,是这样的写法:typedef int bit_32。如果单看后面的int bit_32,则是相当于定义了一个整型变量。

同理,对于typedef void(*KeyboardEventCallback)(int key,int event); ,如果我们单看后面,void(*KeyboardEventCallback)(int key,int event); ,则是定义了一个函数指针。那么类比一下,typedef void(*KeyboardEventCallback)(int key,int event); 定义了一个函数指针类型也好理解了。

对于以函数指针作为形参的函数,我们必须要像这样先定义一个函数指针类型,然后像上面代码第二句一样声明这个函数!

空指针nullptr

C语言种我们定义一个空指针,是这样写的:int *ptr=NULL;

其中NULL是C语言中定义一个(Macro)。NULL的宏定义如下:

1
2
3
4
5
6
7
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL (void *)0
#endif // __cplusplus
#endif // NULL
从这段定义可以看出
  1. 在C语言中,NULL具有二义性,它既指字面常量0,又指空指针。

  2. C++中,NULL只指代字面常量0,我们应该用nullptr来代指空指针。

切记切记,C++的空指针就必须用nullptr!哪怕是新建cpp文件写C代码也要用nullptr。

虽然你的电脑可能认识NULL,但是其他电脑就说不定了,特别是学校的老电脑……


引用(reference)

引用的概念

引用的语法含义就是给一个变量取别名

注意

  1. 引用在定义时就一定要赋值,且之后不能再改变。(下面我们会知道原因)

  2. 通过本名和别名都可以访问到这变量所在的内存空间,如下图:

    可以看出

    ca的引用,相当于a的别名。ac都指向同一片内存空间 0x7fffffffe4bc

也许读者平时在使用VS进行调试。其实gdb作为GNU套件中的一员,也是一个很好用(?)的调试工具。其实主要是学linux要用。如果有时间的话,我以后会写一个使用gdb进行调试的系列。

引用的本质

既然引用就相当于一个变量的别名,那么它在内存中是怎么存在的呢?还是说就是一个宏?

引用的本质是常指针!
  1. 我们定义一个引用,如int &c=a,就等价于int* const c=a
  2. 我们使用一个引用,如c=200,就会被转化为(*c)=200

所以:

  1. 引用不能改变且只能在定义时赋值
  2. 引用和指针一样,也会占空间,且为地址的大小。

但是,我们可以通过和指针的对比来印证这一点。对比下面两图:

  • 使用常指针,打印出变量的地址:

  • 使用引用,打印出a、b变量的地址:

结论

两个程序的区别就是前者用的是指针,后者用的是引用。

通过打印发现,两个程序的ac的地址都是一样的。前者有指针占位(8字节);可以推断后者是引用占位,也占了8字节。这一点足以说明引用的本质就是指针。

图二可以直接打印出b对应的指针的地址吗?

常引用

前面我们知道了引用是一个常指针,因为它不能像一般的指针一样随便指,所以它比一般的指针更安全。而常引用 又比一般的引用更安全。下面我们来了解常引用。

这里的安全是对于程序而言的,越安全的程序其中的数据就越不容易被“某些用户”篡改。而对程序员来说就是更大的负担……但维护程序的安全性也是程序员的职责。

常引用指向常量

我们要给常量取别名,就必须用常引用!

比如,我们必须用const int & a=1;使a来代指1, 而不能用 int & a=1; 如果用了,编译器会报错:cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type 'int' 。因为 1 是不可修改的右值。

特别注意,const 修饰的变量也必须用常引用!


知识点补充:左值与右值

这是C语言中值类别(value category)的知识点。

  • 简单点来说,左值就是可以修改的量,即变量;右值就是不能修改的变量,即常量。
  • 严格点来说,lvalue即locate value,可以进行取地址操作;而rvalue则不能取地址。

常引用指向变量

对于一个变量,如int a,可以有下面两种引用方式:

  • 不使用常引用 int & b=a:这时通过ab都可以修改对用内存空间的值。
  • 使用常引用 const int & b=a:这时只能通过a修改内存空间的值,而用b的话编译器报错

上面两点可以理解为引用的访问权限只能降级,不能升级。即可以将读写改为只读,而不能将只读改为读写。

开始使用引用吧

引用可以在变量原本的作用域里面使用,但是意义不大。

引用更广泛的使用场景是传参

作形参

回到引言里面交换的例子,我们用引用来实现两个数的交换:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
void swap(int& a,int& b){
int temp=a;
a=b;
b=temp;
}
int main(){
int a=1,b=2;
swap(a,b);
printf("%d,%d",a,b);
return 0;
}

以前学习到引用传参的时候,如果按照引用的语法含义“一个变量的别名” 去理解,那么就会疑惑为什么传参的时候别名可以和本名相同。其实从引用的本质“常指针” 的角度来想问题就迎刃而解了。

作返回值

这种用法局限性很大,平时一般不会用到。但看到的时候得明白。

有的时候,我们必须要用返回值返回参数。我们一般的做法是:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
int add(int a,int b){
return a+b;
}
int main(){
int a=1,b=2;
int c=add(a,b);
std::cout<<c;
return 0;
}

用这种方法进行了两次拷贝:第一次是 return 的时候,把 a+b 的值拷贝给临时空间;第二次是在主调函数里面把临时空间的值拷贝给 c

而使用引用,只需要进行一次拷贝: return 的时候,把 a+b 的值拷贝给临时空间。

1
2
3
4
5
6
7
8
9
10
#include <iostream>
int add(int a,int b){
return a+b;
}
int main(){
int a=1,b=2;
const int & c=add(a,b);
std::cout<<c;
return 0;
}

但是,我们要注意它的生命周期 。在下一次使用(即用到了存储这个返回值的寄存器)的时候,这片临时空间将归还给系统。