《征服C指针》(C和C++混版笔记+总结)
这本书主要是写C语言的,甚至旧版本的书中只有ANSI C,很多C99与C11中的东西没有被提及。不过很多东西还是讲的有些意思的,而且由于本人主要也不是学C而是C++因此很多内容只是大致扫了一眼,很多没有自习看细节,很多东西也会尝试将其和C++比较。
基础知识回顾
简单介绍一下C中数组与指针的那些语法,再补充一些C++中的新知识。
关于指针
指针究竟指的是什么,在ANSI C中这么说明:
指针是一种保存变量地址的变量。
其实,指针类型,并不是单独存在的,而是由其他类型派生而成。也即“由引用类型T派生的指针类型称之为指向T的指针”。
由于指针类型是一个类型因此其也存在“指针类型”和“指针类型变量”以及“指针类型的值”。他们都常常被称为指针,但是分明说的是不同的事情,这一点十分容易造成混淆。
指针类型的值,大半指的是实际内存的地址值(指针所指向数据所在的内存地址值)
使用地址运算符& ,可以由输出变量的地址。而指针变量存储的就是相对应的类型的变量的地址值。同时也称指针指向了某变量。
在指针前加上*解引用运算符,可以表示指针指向的变量。
int *p;//声明一个int类型的指针
int num;//声明一个int类型变量
p = #//将num变量的地址赋给指针变量,也即将指针指向该变量
*p = 10;//通过指针改变其指向的变量的内容
如上,可以看到指针的基本用法。当然,指针类型的不同会因为其指向的变量类型不同而不同,也如上所说,指针基于其他类型派生而来。还有一个特殊的“可以指向任意类型的指针类型” void* 类型。
指针运算操作也是十分独特的一种功能。
指针运算是针对指针进行整数加减运算,以及指针间的减法运算。
如对指针p 可以进行自增p++操作,也可以进行p = p + 1的操作。这两个操作的结果在p为 int*类型时候都是将其取值+4,也即其存储的地址值加四,指向内存中下一个int类型变量。这个整数运算+1并不是指将指针变量存储的地址值加1,而是加一个单位大小,单位大小与其指向的变量类型相关,如double大小为8,char大小为1,int大小为4。也正是这一个指针运算的特殊性质,可以表示出指针与数组的关系,具体内容下文会提到。
空指针,是一个特殊的指针值。
空指针保证没有指向任一个对象。通常用宏定义NULL来表示空指针常量,NULL其实是0L,因此很多地方也可以将空指针直接赋值为0,表示一样的效果。(C++中改为使用nullptr)因为NULL很粗暴定义为0后可能会出现一些问题,具体可以看参考中对应的链接。
关于数组
数组是将固定个数且相同类型的变量排列起来的对象。如一个int array[5];在内存空间中排布如下。
一个数组中的元素是顺序紧密排列的。
当然注意,数组的下标是从0开始的,也即array[0]对应的是数组的第一个元素,其余同理,因此当我们要访问数组第i个元素时,下标应选择i-1。
正是因为这样特性,我们在使用一维数组表示二维数组(数组的数组)的时候,可以通过计算对应下标来访问对应元素:访问第line行第col列时候,使用的一维数组中下标为line*width+col。
而且由于数组与指针间微妙的关系,我们知道,给指针加N,表示的是指针前进“当前指针指向的变量类型的长度*N”。因此,给指向数组的某个元素指针加N后,指针会指向N个之后的元素。使用++运算符或者加一对指针操作,指针都会前进sizeof(指针类型)个字节。
当然,也可以通过*(p+i)这样的偏移寻址的方式来访问对应的地址上元素,其实这个方法与p[i]是无差别的。
在C++中,这一个方法就可以完全不一样,因为C++中对运算符操作的重载,可以使得更多更花哨的用法实现。
class safearay{
private:
int arr[SIZE];
public:
.//省略其余操作
int& operator[](int i)
if( i >= SIZE )
cout << "索引超过最大值" <<endl;
// 返回第一个元素
return arr[0];
return arr[i];
int main()
safearay A;
cout << "A[2] 的值为 : " << A[2] <<endl;
cout << "A[5] 的值为 : " << A[5]<<endl;
cout << "A[12] 的值为 : " << A[12]<<endl;
return 0;
比如如上,重载一个类的运算符,可以使得对该类对象使用下标运算符[]时候实现对应的功能通过类的定义将其数据与操作都封装到对象内,此时想要通过指针的方法访问就不行(因为在内存上,不论此类的指针偏移数还是对象的指针指向与对象内数组的位置都不同,此种情况下不论如何都是无法直接使用指针运算符的)。
函数传参:
在函数中传参的时候想要传递一个数组,其实效果与传递一个指针相同。
void f(int *p){}
void f(int p[100]){}
void f(int p[]){}
其实上述三个函数定义可以看做同一个,如果我使用
如上定义,在运行时候,我的Visual Studio会报错说到:
也就是对于这个void fff(int *)这样的一个函数原型已经有了对应的定义主体,然后后边又重复定义导致了错误。说明这三种写法其实对于编译器来说就是一致的。
当然这样也可以看到,其实传参只传递一个指针表示数组的话,并不确定其数组大小,一般需要传递另一个参数告诉函数数组的大小才行。(char*表示的字符串不需要,因为最后以'\0'收尾)
void f(int *p,int size);
C++中的新方案
使用C++,如果不是学校以C的形式教学C++(比如我的本科怨种学校),一般来说都会很快开始熟悉STL库的使用。其实STL库中的数据结构大部分的底层实现都会在数据结构课程中进行学习,而STL库为我们提供了方便的接口以进行使用它们已经实现的功能。比如在C++中,大部分情况下对于数组的使用可以替换为vector。
int nums[10];
vector<int> vec(10);
vec[1];
vec.at(1);//使用此种方式可以避免越界访问,越界直接报错
//使用C++的异常处理方法
try{
cout << vec.at(100);
catch(exception &e){
cout << "standard exception :" << e.what() << endl;
}//会输出invalid vector subscript
STL提供的各种容器更方便快捷且提供了更多功能,开发效率远高于C中的数组实现,当然效率以部分的性能与内存牺牲为代价的,不过相比于其余语言的性能下降,这部分取舍是可以接收的。而且其异常处理也是C语言中所不支持的。
内存的使用
这里讲解实际上C语言如何使用内存,C++中也是类似的,再补充一些新知识。
虚拟地址
对于“地址”,已经说过,变量总是保存在内存中某处,而为了确定内存的不同地方,使用类似于门牌号这样的方法使用地址标识内存空间。
而如果使用同一个代码运行两个不同的程序,可以看到如下情况
这个图是书中原本的截图,可以看到其中两个进程中变量存储在了同一个内存地址处。而且可以看到两个程序是同时都在运行状态的,但是地址却完全一致。
虽然两个进程中变量地址看上去完全一致,不过其实他们是在各自的进程中彼此独立无关的。因为现代的操作系统中会给应用程序的每一个进程分配独立的虚拟地址空间。这部分功能与使用的编程语言无关,而是操作系统与CPU工作的结果。因此,就算一个进程中出现了错误,这个错误也仅仅会使得此进程运行错误,而不会影响其余的进程。
当然,实际上真正去保存内存数据的还是物理内存,操作系统通过一些方法入段页式内存管理等等很多不同方法将物理内存分配给虚拟地址空间,这部分的实现由操作系统来实现,对于应用程序来说是透明的不需要我们去考虑。(实际上如果要考虑高性能比如cache缓存以及不要过于频繁的缺页中断等待问题也需要理解底层操作系统才行)
现在,程序的部分内容以共享库的形式来共享使用已经是很普遍的设计手法了。之所以可以这样,都是依赖于虚拟内存。
C的内存使用方法
C语言变量中有许多不同作用域。
比如一般的变量以被语句块花括号所包围的部分为它的作用域。
static与extern分别控制静态连接与外部连接。对于全局变量,作用域指文件作用域。链接指外部链接。都是用于控制命名空间的。当然C++中有命名空间的概念,比如
std::cout<<"Hello world <<std::endl;
//std为标准命名空间
在C++引入了命名空间后,就很方便通过功能、模块区分不同命名空间来防止变量、功能混乱。
C语言有三种作用域:
全局变量:在函数之外声明的变量,默认为全局变量,在任何地方可见。多文件编译时,也可以通过extern声明从其余文件中引用。
文件内部的静态变量:与全局变量声明位置一样,不过在前边加入static约束,表示作用域限于当前源代码文件。
局部变量:函数中声明的变量,局部变量只在包含它声明的语句块中被引用。通过在其所在语句块结束时被释放,如果不想被释放还可以再局部变量上加static声明,可以保证全局存在,不过仅在局部作用域内可用。
变量除作用域外,还有一个存储期的差别:
静态存储期:全局变量,文件内static变量以及指定的static局部变量,都有静态存储期,统称为静态变量,它们的寿命从程序运行开始到程序关闭时结束,静态变量一直存在于内存同一地址上。(都存储在静态存储区)
自动存储期:没有指定static的局部变量,称为自动变量,在程序运行进入其所在语句块时被分配内存空间,执行完该语句块后被释放,通常在栈区内。
还有一种动态申请的地址由malloc或者new来的空间,在动态申请时被分配内存空间,通常在堆区内,而直到被free或者delete之后才会被释放。
文件内存表示
例子
#include <iostream>
using namespace std;
说明:C++ 中不再区分初始化和未初始化的全局变量、静态变量的存储区,如果非要区分下述程序标注在了括号中
int g_var = 0; // g_var 在全局区(.data 段)
char *gp_var; // gp_var 在全局区(.bss 段)
int main()
int var; // var 在栈区
char *p_var; // p_var 在栈区
char arr[] = "abc"; // arr 为数组变量,存储在栈区;"abc"为字符串常量,存储在常量区
char *p_var1 = "123456"; // p_var1 在栈区;"123456"为字符串常量,存储在常量区
static int s_var = 0; // s_var 为静态变量,存在静态存储区(.data 段)
p_var = (char *)malloc(10); // 分配得来的 10 个字节的区域在堆区
free(p_var);
return 0;
作者:力扣 (LeetCode)
链接:https://leetcode.cn/leetbook/read/cpp-interview-highlights/e4vkxv/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
由上可以看到,有一部分只读内存区域用于记录代码段以及字符串常量。
函数由于通常只会被执行而不会被修改因此也存放在只读代码段部分中。
说到此处其实可以理解,函数指针这样的存在。因为函数调用其实简单从底层理解就是先将传递的参数入栈后再跳转到对应函数的地址处执行函数指令,在完成后再跳转回来。因此如果我们知道函数存放在内存中的地址,应该也是可以调用此函数的。
int func(double d);//函数原型
int (*func_p)(double d);//函数指针声明,可以看到写法为 函数的返回值类型 (*函数指针名) (函数的参数列表)
double d = 12;
func(d);
func_p = func;
func_p(d);//与直接调用函数效果一致
#include<functional>
function<int(double)> fun = func;//C++的functional标准中可以实现类似函数指针的功能,
//再搭配lambda表达式可以实现回调函数的操作,当然还有好多更高深的用法现在还没用过...
fun(d);
当然函数指针也是指针,也可以使用如数组一般的写法(当然数组内所有指针对应的函数原型都是同一个)
int (*func_table[])(double); //指向函数的指针的数组
前边提到了许多不同变量有着不同的生存期,比如全局变量在整个项目中都要保证唯一性,那么程序是如何在不同代码文件中得知这一个变量且将不同文件中同一个变量连接起来呢?
这就是链接器干的事情了。回想大家编程的时候是不是经常会出现一些比如未识别的符号或者类似的报错呢?然后去搜索就发现经常是有些第三方库的lib丢了呀之类的问题。其实原因就是在我们主程序运行过程中使用了其余文件中定义的函数或者变量,在编译的时候,编译器将每一个源代码文件中的各种变量、函数都放到一个符号表里
如上,其实可以看到这一个源文件中的所有变量和函数,可以看到许多不需要去链接的文件内局部变量,不过虽然不需要链接,但是由于静态变量的全局生命周期,因此也要记录这些变量,分配地址给它们。如图中C标识了全局变量,而b标识了其余的变量。函数后有U与T两种不同标识,其中T标识了当前文件中定义的函数,而U标识了非当前文件内定义,仅调用的函数。当然其实自动变量(函数内声明的非静态变量)都没有出现,因为那些变量都在栈区内分配,不需要让链接器感知。
栈
这里的栈与我们数据结构中的栈stack并不是一个东西,虽然他们有相同的性质,不过这里我们栈特指程序内存空间的一个部分,在调用函数时候就压栈记录信息,并且此函数中用到的自动变量都存在于此栈空间中,在函数执行完成后出栈,所有的自动变量也都不可使用了。而此时调用新的函数,可以使用与之前函数完全相同的内存区域也不会出现问题。
如图,栈中首先会保存着函数调用的相关信息,以及在当前执行函数中所使用到的局部变量信息。
函数间调用的概念就是这样,嵌套调用,在栈中保存函数信息,因此可以逐层递归调用也可以按序反向返回。
函数调用实现过程一般如下:
- 一般来说,调用时候会将参数从后往前按序堆积在栈中。
- 函数调用返回的相关信息如返回地址,也堆积在栈中,如上图中灰色的部分。保证函数执行完成后可以返回到调用函数的位置
- 跳转到被调用函数地址处
- 栈为当前函数分配需要的内存区域
- 在函数执行过程中,可能有复杂操作需要将中间值放入栈中
- 函数调用结束,局部变量占用的内存区域被释放,利用返回信息回到原来的地址
- 从栈中出去调用函数的参数
因此在函数中返回一个指向函数内局部变量的指针,会引起错误。
递归调用,指的是函数内对函数自身进行调用。
递归调用的实现依赖于栈空间的特性,它实现了自动保存函数调用之前的状态,这样可以很方便实现递归调用。
以快排算法为例:
快速排序算法其实思路很好理解,首先选定一个元素,经过一轮遍历使得他左边的所有元素都小于他,右边所有元素都大于等于他,这样的话这一个元素就放置到他应该放置的位置,左边排序和右边排序就行,不影响这一个元素了。而这时候就是递归的开始:分别对左右两边进行同样的操作,选取一个元素再在子数组中进行遍历,这样可以接着划分更小的数组直到元素某一边只有一个,就可以减少递归的增加趋势,到最后只剩下两个元素就可以返回。
再返回后,就可以回到上一个调用的点后,接着执行原本的函数,接着从右边去递归,直到最后返回到调用排序算法的地方,此时就完成了对整个数组的排序。
堆
使用malloc()进行动态内存分配。
C语言可以使用malloc动态内存分配,根据参数指定的尺寸来分配内存块,返回指向内存卡初始位置的指针。
p=malloc(size);
当内存分配失败时返回NULL。且可以通过free()释放被分配的内存。
free(p);
在上述操作中分配的内存都在堆上。
其实学过操作系统的同学都应该知道,包括文件读写操作、调用其余程序以及网络通信这样的操作,其实在底层都需要进行系统调用,通过操作系统内核的高优先级才可以完成操作,而普通运行程序是没有这个权限的,只能通过调用操作系统提供的API,以系统调用的形式实现功能。而分配内存显然也涉及到了需要高权限进行的内存管理,那么malloc是否是系统调用呢?
不是。包括很多其余功能如输入输出的printf()scanf(),其实现的功能显然要用到系统调用,但是底层系统调用太过抽象与不方便使用,因此将底层write()函数进行封装,实现了许多输入输出相关的标准库函数。与此类似,malloc是标准库函数,其底层封装了系统调用如UNIX系统下使用brk(),windows下有些调用HeapAlloc()。
malloc实现的功能大致是:首先从操作系统一次性取得比较大内存,然后按需求每次分给程序调用所需要的部分。最简单的实现就是链表方法,将每一块分配的内存作为一个节点,串联起来。
当然还有很多更详细的知识如果想要了解可以去看对应部分内容,比如realloc()calloc()等一系列新的函数以及分配空间中会遇到的一些问题。这些问题在我们需要开发更高效的系统以及应用时会有所帮助,不过在入门时便不需要考虑了,这部分都由编译器与操作系统封装起来我们是感受不到的。
内存对齐
其实很好理解,对于这样的一个结构体:
typedef struct{
int int1;
double double1;
char char1;
double double2;
}Hoge;
计算这个结构体大小应该是多少呢?是结构体中每一个单独元素大小求和吗?
大部分情况下不是的。因为根据硬件CPU的特征,不同数据类型配置的地址会受到一定限制,因此编译器会适当进行边界调整(布局对齐),让结构体插入合适的填充物。
当然,有些情况下double会以4字节对齐,而有些情况下(不同编译器)会配置在以8字节对齐的地址上。
我们可以通过在char后填充三个无用的char变量来手动满足边界调整,不过其实大部分情况下这样好像没啥用...
当然,在DX12的经验来看,很多感觉很奇怪和古老的规则,其实都是更贴近于底层的要求,比如向GPU传递结构体的时候,在声明结构体类型时候,就可以通过改变声明变量的顺序来减少结构体的大小(也是由于字节对齐的原因)。而且还有很多其他的点,在后部举例吧。
字节排序:其实就是一个大端还是小端存放的问题。
比如,一个0x12345678在内存中如何存放呢,因为它横跨了四个字节,在四个字节中如何存放?
- 大端模式
低地址 -----> 高地址
0x12 | 0x34 | 0x56| 0x78
- 小端模式
低地址 -----> 高地址
0x78 |0x56 | 0x34 | 0x12
具体使用何种方式其实与CPU不同而不同,当然这只是整数的表示方法,还有更复杂的浮点数表示,具体的表示可以在《计算机组成原理》课程中学习,课程中会讲到更多底层知识,也会对编程中许多原本不理解的地方进行解释。
C语法的理解
讲解了与数组和指针相关的C语言语法,如何理解。
解读C的声明
对于一个声明
int (*func_p)(double);
如何理解呢?当然我们在编程多次后可以很轻松看出这是一个函数指针,而函数原型是 int f(double)。但是如何理解这个呢?
首先查看标识符可以表示为: int (* func_p ) (double);
func_p is
然后存在括号,看到了*符号: int (*func_p) (double);
func_p is pointer to
然后在看到了后边解释函数的()以及参数double: int (*func_p) (double) ;
func_p is pointer to function(double) returning
然后在看到了后边解释函数的()以及参数double: int (*func_p) (double) ;
func_p is pointer to function(double) returning int
最后翻译为中文:
func_p是指向返回int的函数的指针。
类似的在书上总结为:
C语言 | 英文描述 | 中文描述 |
---|---|---|
int hoge; | hoge is int | hoge是int |
int hoge[10]; | hoge is array of int | hoge是int数组 |
int hoge[10][3]; | hoge is array(10) of array(3) of int | hoge是int数组的数组 |
int *hoge[10]; | hoge is array of pointer to int | hoge是指向int的指针的数组 |
int (*hoge)[3]; | hoge is pointer to array of int | hoge是指向int的数组的指针 |
int func(int a); | func is function(with parameter int) returning int | func是返回int的函数(参数为int) |
int (*func_p)(int a); | func_p is pointer to function(parameter int)returning int | func_p是指向返回int的函数(参数为int)的指针 |
这样的理解方法去理解,个人感觉比较好明白。当然更具体数组与指针的不同还是要下边介绍。
数据类型
数据类型有基本类型与派生类型两种。基本类型就包括int、double、char这样的最基础的数据类型,而包括结构体、数组、函数、结构体以及共用体在内都称之为派生类型。
以一个书上的例子来说明:
int (*func_table[10])(int a);
可以解释为指向返回int的函数(参数为int)的指针的数组。
画成图表示为:
使用这样的类型链表示,首先在链的最末端int这样的可以称之为基本类型,而其余链的部分都可以看做是派生类型,且这些派生的方法可以递归使用,也就是说可以生成无限的类型。
派生方法如:给出类型对象的数组;返回给出类型对象的函数;执行给出类型对象的指针;包含一系列对象的结构体;包含各种类型数个对象的共用体。
指针引用类型,指针指向的全体元素被称为引用类型。
这里也可以看出指针运算的实现,对于指针的加一,偏移的是整个源类型T的大小,而不是仅仅将地址+1。
类似的是数组的表示,因此可以说明,并没有二维、三维数组,而是数组的数组,也就是数组内的元素是一个数组而已。具体可以表示为
int hoge[3][2];
而此时传递参数时,可以声明函数参数为
void func(int hoge[][2]);
void func(int (*hoge)[2]);
这两个参数表明我们传递的是指向int[2]的指针,也可以说是以int[2]为元素的数组,这样的话对指针进行指针运算,就知道应该下移到下一个int[2]数组的开头。
当然也可以进行函数类型的派生:
一方面是返回值类型可以派生,一方面是参数类型可以派生。
当然,函数是不好计算大小以及其余一系列操作的,因此函数指针并不支持指针运算,也不能从函数类型派生出除指针外的其余类型了。
另一种就是结构体的表示了,结构体与共用体表示都不是线性地包含一个类型,而是包含了多个。结构体成员排列地分配在内存中,而共用体成员重叠地分配内存中。
不完全类型,指的是当前派生类型中包含了 其余派生类型 ,而此 类型只是声明了并没有定义 ,则当前派生类型就成为 不完全类型 。不完全类型意味着并 不知道其内容 , 无法确定大小 ,因此无法对不完全类型声明为数组或结构体、共用体成员。不过若是不完全类型派生出的指针,由于指针大小确定,因此可以实现。且,在不完全类型的定义出现后,就不再是不完全类型了。
表达式
表达式究竟是什么?其实和数学中常用的算式类似,基本表达式定义为:
- 标识符(变量、函数名)
- 常量
- 字符串常量
- 使用()括起来的表达式
然后对基本表达式采用运算符相互连接,最终得到的结果仍然是表达式。
可以使用一个二叉树结构表示表达式:
此树的任何子树部分也都是一个表达式。
左值与右值。
当我们书写一个赋值语句时候,很容易写出比如:
int hoge=5;
piyo = hoge*10;
而我们可以改写为
piyo = 5*10;
其与上述表述一致,但是如果我们在
hoge = 10;
这样的赋值语句中,同样进行改写
5 = 10;
这显然会报错,置换显然是非法的。
而这就涉及到了左值与右值的问题。同一个变量名,在不同地方同时可以表示“自身的值”以及“自身的内存区域”两种表达。
当变量参与运算出现在赋值右边的情况下,大部分都表示代表自身的值,我们称这个表达式为右值;而当变量出现在赋值号左边表示内存区域的时候,称之为左值。
比如在上述例子中,hoge参与了hoge*10运算,代表的是自身的值,因此它是右值;而piyo要接收计算结果,表示了内存某个区域,因此其为左值。
const相关:
char * const src;
const char *src;
char const *src;
const char * const src;
这四种非常离谱的声明是否见过呢。我们知道const表示只读,也就是不可改变其值。const其实不一定表示常量,因为传参时候可以有如:
char strcpy(char dest,const char src);
其实const此处就不表示常量,只代表“只读”,也即内容不会被修改。而c++中有一个新特性constexpr有兴趣可以去看看,这个表示将计算的过程放在编译过程,也即在程序运行过程中保证是一个常量,不过是用于表示一个函数的关键字,不是用于声明变量的,而且也要用于不会对对象造成影响的函数内,(即使用constexpr一定要保证函数内是const约束的)。
回到上述的声明中,对指针的const声明有两种,下边来分别介绍。
char * const src;
src = p; //非法,只读指针不允许改变指向
*src = 'a';//合法
src is read-only pointer to char.
src是指向char的只读的指针。
const char * src;
src = p; //合法
*src = 'a';//非法,只读的char不允许改变指针指向内容
src ispointer to read-only char.
src是指向只读的char的指针。
当然,还有一种特殊情况如下所示:
typedef struct{
char *title;
int price;
char isbn[32];
}BookData;
BookData a;
const BookData * const book_p = a;
book_p->price = 12;//可以保证不改动指向也不改动指向的对象,但是依然可以改变掉对象内的元素
数组与指针不同
指针和数组的不同其实还是比较好理解的,真正导致问题的是两者在使用中混为一谈。
其实可以看出,数组是一些对象排列后形成的,而指针表示指向某处。他们完全不同。
数组与指针常常被混为一谈进行混用,实际上只有在声明函数形参的时候,才可以将数组声明解读为指针的声明。
其实指针的数组与数组的数组在内存中的形式也是有所不同的:
char *color_name[]={
"red",
"green",
"blue"
其表示形式如上,是一个数组,数组中每个元素都是指针,指针指向的才是字符串。
char color_name[][]={
"red",
"green",
"blue"
而“二维数组”形式表示,其实是数组的数组,其中每一个字符串都被看做一个字符数组,然后作为元素组成一个新数组。
数组和指针常用方法
实践篇,举例说明数组和指针常用用法,以及C++中一些新的好玩的东西。
基本用法
以函数返回值之外的方法来返回信息
在很多情况下,返回值是用来返回信息的,而更多情况下,其实对某一个操作是否成功也需要有所表示,因此返回布尔变量表示操作是否成功,而如何返回信息呢,在函数的参数列表中传入指针,通过对指针指向对象进行填充内容来返回信息。
这样的实现有很多,比如:
void func(int *a,double *b){
*a = 5;
*b = 3.5;
}
通过指针将两个数进行了赋值操作。
还有很多如DX或者OpenGL这样的API里的设计,都也是用到了这个设计。
另外,在我们实现数据结构的时候,也经常会进行类似的设计如:
int Stack_push(Stack *stack, void *num);
其中Stack为堆栈的结构体,num为要入栈的信息,而如果要出栈或者查看栈顶元素等等,也需要参数列表中携带信息,而返回值int可以定义操作成功返回1,失败返回0。(C++中是返回bool类型咯当然)。
将数组作为函数参数传递
数组本身是不允许作为参数传递的(想想也是,看看那个函数调用的栈区表示,要是把整个数组都放到参数里是不是太恐怖了),不过可以通过传递指向数组初始元素的指针使得函数内操作数组成功。
比如:
void func(int *array,int size){
for(int i=0;i<size;i++)
array[i];//访问数组元素
int main(){
int array[] = {1,2,3,4,5};
func(array, sizeof(array)/sizeof(int));
可变长数组
一般情况下,C语言编译时候必须知道数组元素个数,不过也可以通过malloc在运行时动态申请内存,这种数组称之为可变长数组。而且对于以及分配了数组大小的动态内存,还可以采用realloc进行修改空间大小。
组合用法
文章中这一部分使用了一个非常好的读文件和记录画图曲线数据的两个例子来说明这些用法。我个人看来除了了解对指针、数组的用法外,也很透彻理解了对于系统层面底层的文件读写功能实现的思路,这些功能就像是操作系统内提供功能实现的代码一样简练优雅,有兴趣的同学最好还是去看原书中代码,由于篇幅过长且其中用法不多,因此此处便不进行转述。
可变长数组的数组
一般都是使用指针数组来表示,结构如下
通过声明一个指针数组来实现可变长数组的数组。
可变长数组的可变长数组
由于此时指针数组的长度也是可变的需要动态分配,因此此时采用一个二维指针来实现效果,结构如下
通过声明一个二维指针来动态分配一个
命令行参数
对于命令行参数其实就是上述提到的可变长数组的可变长数组,可以看到
1如果使用很多的命令行命令,就会知道这命令行参数中的信息作用了。
通过参数返回指针
前文已经提到过,由于返回值可能需要返回操作的状态信息,因此返回信息需要通过参数进行返回。(在C++中除了指针外,也可以通过引用传递实现同样的功能)
将多维数组作为函数参数传递
这部分内容扩展到后边小实验中,具体实现与解释看后文。
C++中的智能指针
这部分内容很大部分引用了一个博客,给出链接在最后
在c++中,智能指针一共定义了4种:
auto_ptr 、 unique_ptr 、 shared_ptr 和 weak_ptr 。其中, auto_ptr 在 C++11已被摒弃,在C++17中已经移除不可用。
首先是为什么要引入智能指针呢?看下一段代码:
ClassName *p = new ClassName();
p -> func();
delete p;
这一段代码是我们经常能够看到的功能代码。
而其中存在的问题就在于在使用结束后常常忘记释放内存进行delete操作。而另一方面,C++引入了异常处理机制,若是在func()中产生异常,很有可能会跳过后边的delete操作。
此时,智能指针就可以方便我们控制指针对象的生命周期。在智能指针中,一个对象什么情况下被析构或被删除,是由指针本身决定的,并不需要用户进行手动管理。
unique_ptr :unique_ptr是 独享 被管理对象指针 所有权 (owership)的智能指针。unique_ptr对象封装一个原始指针,并负责其生命周期。当该对象被销毁时,会在其析构函数中删除关联的原始指针。
void f1() {
unique_ptr<int> p(new int(5));
unique_ptr<int> p2 = std::move(p);
//error,此时p指针为空: cout<<*p<<endl;
cout<<*p2<<endl;
创建unique指针声明很普通,而特殊点在于,其是独享被管理对象的,不允许两个指针同时指向(如果这样操作会报错),而想要赋值就只可以使用移动语义move将其所有权进行转移,而且转移后原有指针失去指向。
shared_ptr :shared_ptr也在实际应用中广泛使用。它的原理是使用引用计数实现对同一块内存的多个引用。在最后一个引用被释放时,指向的内存才释放,这也是和 unique_ptr 最大的区别。当对象的所有权需要共享(share)时,share_ptr可以进行赋值拷贝。
//std::shared_ptr<int> p4 = new int(1); //这种写法是错误的
//右边得到的是一个原始指针,而shared_ptr本质是一个对象,将一个指针赋值给一个对象是不行的。
void f2() {
shared_ptr<int> p = make_shared<int>(1);
shared_ptr<int> p2(p);
shared_ptr<int> p3 = p;
通过上述操作都可以得到智能指针。
void ff2() {
shared_ptr<int> p = make_shared<int>(1);
int *p2 = p.get();
cout<<*p2<<endl;
通过智能指针的.get()方法可以获得其原始指针,赋值回普通的指针类型。
当然也有一些问题要注意:
void f3() {
int *p0 = new int(1);
shared_ptr<int> p1(p0);
shared_ptr<int> p2(p0);//不行,因为两个智能指针都会进行析构操作,对同一个指针删除两次会出问题
cout<<*p1<<endl;
而另一个问题在于循环引用:
struct Father
shared_ptr<Son> son_;
struct Son
shared_ptr<Father> father_;
int main()
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
main 函数退出之前,Father 和 Son 对象的引用计数都是 2。而在son 指针销毁时,这时 Son 对象的引用计数是 1。在father 指针销毁,这时 Father 对象的引用计数是 1。由于 Father 对象和 Son 对象的引用计数都是 1,这两个对象都不会被销毁,从而发生内存泄露。
为避免循环引用导致的内存泄露,就需要使用 weak_ptr。weak_ptr 并不拥有其指向的对象,也就是说,让 weak_ptr 指向 shared_ptr 所指向对象,对象的引用计数并不会增加。
使用 weak_ptr 就能解决前面提到的循环引用的问题,随便修改其中一个智能指针为weak_ptr类型即可。
struct Father
shared_ptr<Son> son_;
struct Son
weak_ptr<Father> father_;
int main()
auto father = make_shared<Father>();
auto son = make_shared<Son>();
father->son_ = son;
son->father_ = father;
return 0;
C++的类与对象(动态多态)
这部分对于虚表的说明也引用了博客,最后给出链接
在OOP中,最重要的三大思想就是封装、继承以及多态。封装指的就是类与对象的实现,而继承指的是类之间的继承关系,多态,指的是接口的多种不同的实现方式即为多态。(调用同名函数却会因上下文的不同而有不同的实现。)
多态分为了静态多态以及动态多态,其中静态多态指的是调用同名函数之时参数列表并不一致,此时就可以通过同名函数调用实现不同功能了:
void f(int *list[]) {
cout << "this is *list[]" << endl;
void f(int list[][10]) {
cout << "this is list[][10]" << endl;
void f(vector<vector<int>> list) {
cout << "this is vector" << endl;
int main(){
int mat[10][10];
int* mat_p[10];
for (int i = 0; i < 10; i++)
mat_p[i] = new int[10];
f(mat);
f(mat_p);
vector<vector<int>> v;
for(int i=0;i<10;i++)
v.push_back({ 0,1,2,3,4,5,6,7,8,9 });
f(v);
这是最后例子中的代码,可以大致看出静态多态效果如何了。
而动态多态的实现,也与指针有着不小的渊源:
多态的常规用法:用一个 父类的指针 去 调用子类中被重写的方法 。
#include <iostream>
using namespace std;
class base
public:
virtual void go();
void base :: go ()
cout << "base.go" << endl;
class sub : public base
public:
virtual void go();
void sub :: go ()
cout << "sub.go" << endl;
void fun (base& p)
p.go ();
int main(int argc, char *argv[])
base b;
sub s;
// 通过 基类引用的方式
fun(b);
fun(s);
// 通过 基类指针的方式
base *pb = &b;
pb->go();
pb = &s;
pb->go();
return 0;
可以看到不论是引用还是指针的方法,都可以实现动态多态的效果。
而在面试时候,面试官很喜欢问到的问题就是:动态多态实现原理是基于什么呢?虚表。而虚表相关知识了解如何呢?是一个类共有还是对象独有呢?虚表存了什么信息呢?下文来介绍。
对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
虚函数表通常是一个类共同拥有一个的,而拥有虚函数的对象的第一个元素就是一个指针,这个指针指向了该类的虚表。
而对于继承基类的派生类对象所拥有的虚表如何表示呢?
此时派生出一个派生类,派生类同时声明了新的虚函数,将其信息添加在虚表中基类虚函数信息的后边,若是此派生类中覆盖实现了对应基类的虚函数,只需要在虚表对应位置将函数入口地址进行修改即可。
而C++的继承与JAVA不同,C++允许多继承,而JAVA只允许单继承,不过可以有多个接口。
在C++多继承中,派生类拥有多个虚表,该如何实现呢?
实际上,就是将多个基类的虚表按照继承顺序放入派生类中,再将覆盖掉的函数进行替换,同时派生类中新声明的虚函数就放在第一个基类的虚表中了。
派生类虚表创建:
1.先将基类的虚表中的内容拷贝一份
2.如果派生类对基类中的虚函数进行重写,使用派生类的虚函数替换相同偏移量位置的基类虚函数
3.如果派生类中新增加自己的虚函数,按照其在派生类中的声明次序,放在上述虚函数之后
为什么要把基类的析构函数定义为虚函数?
在用基类操作派生类时,为了防止执行基类的析构函数而不执行派生类的析构函数。因为这样的删除只能够删除基类对象, 而不能删除子类对象, 形成了删除一半形象, 会造成内存泄漏。
一个小实验
首先是很久之前小同学问过的问题,当时有一个简单的实验,也很多都不解释了,这次想了想可能需要更多的东西就先把之前的回答放到这里,再以此为基础继续后续部分
其实可以看出来,对于hoge这样数组的数组,hoge相当于是一个二维指针(指向数组的指针),因此可以首先通过一个指针运算先偏移到高维数组的地方。这也是为什么函数声明时候只可以省略最高维数组的大小的原因,因为其余维度数组大小要用来进行偏移操作。而*(hoge+i)其实是一个一维指针,表示他仍然可以进行指针运算来偏移到对应想要访问的数据上。
额外的想法
#include <iostream>
#include <vector>
using namespace std;
void f(int *list[]) {
cout << "this is *list[]" << endl;
void f(int list[][10]) {
cout << "this is list[][10]" << endl;
void f(vector<vector<int>> list) {
cout << "this is vector" << endl;
void ff(int* list[][3]) {
cout << "this is *list[][3]" << endl;
void ff(int list[][2][3]) {
//这里可以看到只有最高维可以不给出
cout << "this is list[][2][3]" << endl;
void fff(int *p) { cout << "*p" << endl; }
int main()
int nums[] = { 1 ,2,3,4,5 };
int* p = nums;
for (int i = 0; i < sizeof(nums) / sizeof(int); i++)
cout << *(p+i);
cout << endl;
fff(nums);
fff(p);
int mat[10][10];
int* mat_p[10];
for (int i = 0; i < 10; i++)
mat_p[i] = new int[10];
cout << "二维数组:" << endl;
cout << hex << &mat << endl;
cout << &mat[0] << endl;
cout << &mat[0][9] << endl;
cout << &mat[1][0] << endl;
cout << &mat[1] << endl;
cout << &mat[2] << endl;
cout << "一维指针数组:" << endl;
cout << &mat_p << endl;
cout << &mat_p[0] << endl;
cout << &mat_p[1] << endl;
cout << &mat_p[2] << endl;
cout << &mat_p[0][0] << endl;
f(mat);
f(mat_p);
vector<vector<int>> v;
for(int i=0;i<10;i++)
v.push_back({ 0,1,2,3,4,5,6,7,8,9 });
f(v);
try{
cout << v.at(0).at(100);
catch(exception &e){