简单介绍一下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 << "索引超过最大值" <
比如如上,重载一个类的运算符,可以使得对该类对象使用下标运算符[]时候实现对应的功能通过类的定义将其数据与操作都封装到对象内,此时想要通过指针的方法访问就不行(因为在内存上,不论此类的指针偏移数还是对象的指针指向与对象内数组的位置都不同,此种情况下不论如何都是无法直接使用指针运算符的)。
函数传参:
在函数中传参的时候想要传递一个数组,其实效果与传递一个指针相同。
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++(比如我的本科怨种学校),一般来说都会很快开始熟悉STL库的使用。其实STL库中的数据结构大部分的底层实现都会在数据结构课程中进行学习,而STL库为我们提供了方便的接口以进行使用它们已经实现的功能。比如在C++中,大部分情况下对于数组的使用可以替换为vector。
int nums[10];
vector 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语言变量中有许多不同作用域。
比如一般的变量以被语句块花括号所包围的部分为它的作用域。
static与extern分别控制静态连接与外部连接。对于全局变量,作用域指文件作用域。链接指外部链接。都是用于控制命名空间的。当然C++中有命名空间的概念,比如
std::cout<<"Hello world <
在C++引入了命名空间后,就很方便通过功能、模块区分不同命名空间来防止变量、功能混乱。
C语言有三种作用域:
全局变量:在函数之外声明的变量,默认为全局变量,在任何地方可见。多文件编译时,也可以通过extern声明从其余文件中引用。
文件内部的静态变量:与全局变量声明位置一样,不过在前边加入static约束,表示作用域限于当前源代码文件。
局部变量:函数中声明的变量,局部变量只在包含它声明的语句块中被引用。通过在其所在语句块结束时被释放,如果不想被释放还可以再局部变量上加static声明,可以保证全局存在,不过仅在局部作用域内可用。
变量除作用域外,还有一个存储期的差别:
静态存储期:全局变量,文件内static变量以及指定的static局部变量,都有静态存储期,统称为静态变量,它们的寿命从程序运行开始到程序关闭时结束,静态变量一直存在于内存同一地址上。(都存储在静态存储区)
自动存储期:没有指定static的局部变量,称为自动变量,在程序运行进入其所在语句块时被分配内存空间,执行完该语句块后被释放,通常在栈区内。
还有一种动态申请的地址由malloc或者new来的空间,在动态申请时被分配内存空间,通常在堆区内,而直到被free或者delete之后才会被释放。
例子:
#include
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;
}
由上可以看到,有一部分只读内存区域用于记录代码段以及字符串常量。
函数由于通常只会被执行而不会被修改因此也存放在只读代码段部分中。
说到此处其实可以理解,函数指针这样的存在。因为函数调用其实简单从底层理解就是先将传递的参数入栈后再跳转到对应函数的地址处执行函数指令,在完成后再跳转回来。因此如果我们知道函数存放在内存中的地址,应该也是可以调用此函数的。
int func(double d);//函数原型
int (*func_p)(double d);//函数指针声明,可以看到写法为 函数的返回值类型 (*函数指针名) (函数的参数列表)
double d = 12;
func(d);
func_p = func;
func_p(d);//与直接调用函数效果一致
#include
function 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的特征,不同数据类型配置的地址会受到一定限制,因此编译器会适当进行边界调整(布局对齐),让结构体插入合适的填充物。
然后今天就讲到这里啦,大家记得点赞收藏,分享转发,关注小哥哥哦! 最后,如果你想学或者正在学C/C++编程,可以加入小编的编程学习C/C++企鹅圈