C语言进阶--非常规指针&内存操控&编码规范

一、一些常用的基本操作&指令:

①gcc的编译操作指令:

②设置Path的方法:右键windows图标,选择系统,右侧选择高级系统设置--点进环境变量里面即可添加&编辑环境变量

③前置知识的一些补充:

1.八进制&十六进制存在的意义:单纯二进制表示数字的话位数太多太过繁琐,由此可以用三位/四位二进制数映射成一个数字来表示。实际上,十六进制其实更好,表示起来更方便

※PS:以前使用八进制多,现在使用十六进制多的原因:

2.遗留一些问题,后续解决:

这两个关键词的意义?应用场景?

3.对于关键字的分类思路:
(1)内存分配行为单词--包含基础数据类型关键字,int,char等

(2)逻辑处理行为单词--包含分支处理行为关键字(switch&default&if等)与循环处理行为关键字

(3)自定义数据行为单词--包括enum&struct等

二、一些另类写法指针:

①引入:空间的操作方式--有名访问&无名访问

※②指针的要素:一个是大小,一个是访问的方式,更通俗一些可以称为步长

大小——往往不那么值得关注,无非就是32位4个字节,64位8个字节,于是我们更该关注的是指针的访问方式

拿int*p 和char* p作比——决定p大小的是*符号,决定了它是个指针,再依据操作系统来分配大小。由此前面的int&char这样的数据类型决定的其实是这个指针的访问方式,也可以理解为这个指针每次访问移动的步长。于是,指针+1,+5等,其实是加了1个&5个步长

※所以,如果有char*p=100这样的赋值,是不对的。虽然这样指针可以知道它的大小,知道它当前的位置,但是它没法获得步长信息,也就是如果指针++或者--等,是无法正常完成访问的。

③阅读一些另类指针的方法:

 比如int* a4[5]---先往右看,a4升级为数组,把整个a4当作整体,紧接着往左看,左边要回答的是每个元素的类型--发现是int*,于是a4代表的是一个五个元素的整形指针数组--也就是说,*修饰的其实是a4[i],而不是a4

再如int (*a5)[5]---先往右看,发现遇到了括号,不读,往左看,发现有*,于是a5先升级为一个指针。升级为一个指针之后,回答两个问题,一个是大小,一个是访问方式。先往右看,发现是[5],所以访问空间就是五个五个的访问。再往左看,回答的问题是五个元素中每个元素是什么,发现是int。因此,a5访问空间的方式是一次跨过5个int

再如int a6[3][4],其实就是个线性组合。一共有三串东西,每一串有4个int,a6即是一次访问4个。a6和a5的区别,只是a6指明了一共会访问三串,一串5个int。而a5没指明一共访问几串

再如int *a7[3][4],就是一个元素为int*类型的,三行四列的数组。

再如int (*a[5])[4],先看a,a是一个有五个元素的数组,每个元素里放了一个指针。紧接着回答每个指针的问题--向右看,每个指针的访问方式是4个4个的访问,每个是int。或者更通俗一些--其实就是a代表了五个抽屉,每个抽屉里存了一把钥匙。那把钥匙的访问方式是4个int4个int的走

再如int a9[5][3][4],先看[3][4],是一个3*4的访问方式,然后一共五个这样的平面

再如int(*a8)[3][4],同样的a8先升级指针,然后回答问题,怎么去访问--发现是一个面一个面的访问(3*4的面,每个是int)

※既然:指针+1,是走一步,所以对于a9&a8,+1就是挪到下一个面,(当然也是线性存储的,其实就是前移3*4*sizeof(int)个字节的地址)

另外一种比较另类的指针——void*,它的作用基本上只在于接收地址值(这样返回地址的时候,就没必要非心思思考返回的是什么类型的地址值来把返回值设为char*&int*等了,直接用void*就可以接收返回的地址值了),因为void*的操作相当于未定义,没有类型也就没有步长,所以它其实并不是为了进行一些操作而引入的。

因此,拿void*接收地址很香,无论是作为返回值还是形参,使用时再强转类型(或者说赋予它一个类型),使它的操作能够被定义即可。

 所以,对于下面将会说到的更规范的传参写法,也可以这样修改成void*的形式。

④C与Cpp在内存访问方面的一个比较大的差异——C实际上是允许一点一点的访问内存的,但是Cpp不允许,Cpp要求你必须按照步长一步步的走。

比如:

int a[5][3][4];
char *p=a;

这样的写法在C中是允许的,这样之后我们实际上就只关心p这个指针的行为了。就像一个一块50平米,有4块这样的地的一共200平米的房子,我们却非要去一平米一平米的来访问——C允许这样,但Cpp不允许。Cpp必须需要你用同样级别的指针来相互赋值。

 

这其实也体现了C和Cpp“面向”的不同——Cpp更加面向业务,所以他需要严格的类型检查。而C面向底层,它的标准也非常开放,使得使用者拥有极大的自由。同样地,也保证了访问内存的自由。

三、函数指针:多态的底层实现方式

譬如这样的一个例子:

函数名的本质,是一个很特殊的地址值!

①定义:拆解来看,先是中间:(*p),括号括起来表示它是一个指针,然后右边描述它代表的函数的传参,左边描述它代表的函数的返回值类型。同样的,按上面的说法,左边右边的东西,是用来修饰“抽屉里的东西”的。

有的时候如果想要有指向库函数的函数指针,可以直接去库文件里面复制库函数的声明——

※函数名本身是个常量,但是函数指针可以实现变量的效果!

 ②内存空间相关的一些补充知识:

(最底下的VA的意思其实是Virtual Address,也就是虚拟地址)

比较重要的:

(1)栈--要注意变量出栈之后,原本的内存空间就不再受保护了,即变为可用的。后续为局部变量分配空间的时候就可能分配到这个地方——因此不要返回局部变量的引用等!

但是变量是否是严格的先进后出来入出栈,实际上是编译器的行为,不太好笃定——

我们观察a最终以十六进制形式输出的结果为12345699就可以看出,buf[4]占掉了a的一部分内存空间。但是就像前面说的一样,编译器不一定会规规矩矩的挨得这么紧凑的玩栈,所以如果程序异常,不要通过这种的值的改变来推断,仍然应还是从防止下标越界这个方面来防止程序的BUG!

同样地,我们观察这个程序,发现a明明指定的是const,但是还是被篡改了——这是因为C中的const关键字只在编译阶段保证变量的值的一定,在运行阶段就无法保护了。

不过Cpp中的const是真正的const——

(2)data区--尤其是全局(定义于函数块外)&静态变量所在的区,生命周期是整个项目。因此如果有这样一段代码:

void func()
{
    static a=5;
    a++;
}
int main()
{
    
}

如果多次调用func函数,从第二次开始,a就会在之前的值上++,而不是重新赋值为5——一定要区别于下面的这种写法。下面的这种写法是赋值操作,自然会不断地赋上5;而上面的写法,是初始化操作,从第二次遇到开始,编译器就会自动把这条语句当作声明语句,而不会重新再定义&初始化一遍a。

void func()
{
    static int a;
    a=5;
}
int main()
{
}

这个例子还有另外一个看待的方面——理论上来说,但从变量在内存中的存在方式来看,main块是完全可以访问这个a变量的,只是没有一个名字给main访问。

※也就是说:a这个变量是全局的,但是a这个名字,是局部有效的

rodata--read only data,亦即可读数据区,和代码区都是只读的——代码区要是会被乱改的话,整个程序就乱套了(这里的改指的不是这样敲代码来更改,指的是无法通过一些其他的奇奇怪怪的方式修改)。由此,如果有char p[6]="Hello";这样的语句,编译器实际上做的工作是把Hello与'\0'一个个的读到p这个字符数组里。这是一个拷贝的工作,从只读段一个个的拷贝到char所在的数据存储段。所以在内存的不同区域其实有两份Hello,一份在rodata区,也就是字符串常量所在的地方,另一份取决于p定义的位置位于全局变量区或者局部变量去(栈)。

同样地,还有一个很重要的区别————

char p[]="Hello";与char*p="Hello";两种方式定义出来的p是不同的!第一个p是一个字符型数组,它的内容是从字符串常量段复制来的,所以它就是个朴实的char*;而第二个p是指向字符串常量的,因此它实际上是const char*!

※由此:对于变量的定义来说,定义在哪,也就是这个变量将会位于内存的哪个位置,是由操作系统来决定的;而变量的具体的大小,是由使用者,也就是程序员来决定的。

③typedef的一些补充知识:

※注意typedef永远跟着变量来看,而不是跟着类型来思考————也就是说,把typedef遮起来,后面的那块就成了一个变量的定义。那么在这个定义之下得出的对应的变量的类型,就是那个变量可以代表的类型!

拿一个例子来品:

int timez_t;//timez_t是int类型的变量
typedef int timez_t;//timez_t是int类型的别名

只有两个字的差别--因此在遮掉typedef,看出类型之后,把“变量”二字置换成“别名”,就知道代表的类型了。

※不过typedef更重要的作用其实是保证可读性,并且像宏定义一样便于批量修改

※再来一个例子:关于typedef数组的问题--譬如 typedef int array[5];--定义了一个新类型 array。
然后这个array是一个大小为5的数组,每个数组元素类型为 int。
所以如果后续函数传参等,其中写明了array*p,其实也就相当于是指明了p是指向这样一个数组的指针。总之就是遮住typedef之后array是啥,它typedef之后就代表啥类型
所以如果想要访问这个数组的元素要先解引用得到这个数组,比如(*p)[0]

④接续函数指针:

1、函数指针的理解方式:

仍然从“回答问题”的角度来思考函数指针!

2、函数传参的艺术:

 地址传值,在单纯的阅读代码上,具有“三义性”——有可能是传类&结构体的指针,为了节省空间,也有可能单纯的传一个变量为了反向更新它的值;也有可能是传只读连续空间,比如传个字符串只为了读,或者是要实质上的修改等。所以引出了三种的书写地址传值的方式——

(1)如果是为了反向更新变量的值,朴实的传指针就好,比如int*

(2)如果是为了只读连续的变量空间,加const,比如const int*,但是最好也传入数量,也就是像这样:(对于字符数组,传char*一般代表字符数组,传const char*一般代表字符串)

void read_arr(const int*arr,int num)
{

}

因为即使字符串有结束符来标识,传入数量仍然是更安全的选择,所以很多从缓冲区把数据读入到字符串,或者是赋值字符串的函数的安全版本(有_s后缀的,或者strncpy),都是需要在参数中指明读入的字符数的。比如strcpy_s&strncpy之类的——

 读这种库函数声明的时候,__restrict__这种的标志符,也就是两边都有下划线的这种写法的符号,多半是给编译器看的,自己读的时候只看其他部分即可。

所以strcpy实际上是不允许使用的,因为太容易读取过量的字符导致越界了。

(3)如果是修改连续变量的值,单纯传int arr[](它在编译器看来,从传参方面,和int*一模一样,编译器是不会把它看成一个数组的,因为[]本身就是个解引用的语法糖,比如p[1]==*(p+1))或者int*,和(1)区别不大开,所以仍然需要传入num,也就是长度。

以上四种的传参写法,在各自的应用场景下,是比较规范的。

※一个传函数指针作为形参的烂活:

就我个人而言,我觉得如果形参确定是传函数指针的话,写void会降低可读性,但是如果要把函数指针统一成其他指针,做一样的操作的话,传void*是最好的选择。

3.关于函数的返回值:

留一个尾巴,或许在设计模式中会品到这种返回值的作用——

返回的地址,也可以是一个函数的地址

※要注意,千万不要返回局部变量的引用&指针等,虽然接收返回值之后能操控那块空间,但是那块空间是不再被受保护的,后续分配空间也可能会分配到那里去!

4.函数设计的一些注意事项:如果输入在main,那么输出可以在main也可以封装成函数。但是如果输入&开辟空间封装成了函数,最好把输出&释放空间的操作也封装成函数。这样可以实现操作上的“配对”。

四、C/C++的下标检查:

以下这两段文字摘自:C/C++ 不检查数组下标是否越界 - Slyar Home

原来C/C++是不检查数组下标是否越界的?奇怪的事情。。。不检查下标是否越界可以有效提高程序运行的效率,因为如果你检查,那么编译器必须在生成的目标代码中加入额外的代码用于程序运行时检测下标是否越界,这就会导致程序的运行速度下降,所以为了程序的运行效率,C/C++才不检查下标是否越界。

自己写了一段检测程序测试这个问题,发现如果数组下标越界了,那么它会自动接着那块内存往后写。想了一下明白了,以前说不允许数组下标越界,并不是因为界外没有存储空间,而是因为界外的内容是未知的。也就是说如果界外的空间暂时没有被利用,那么我们可以占用那块内存,但是如果之前界外的内存已经存放了东西,那么我们越界过去就会覆盖那块内存,导致错误的产生

注意:下标引用,也就是[],是可以应用于任何指针的,也仅仅是相当于一个语法糖。

※中括号内的值指定的其实是偏移量,也就是相较于指针所在的当前位置,向内存前或后便宜多少个步长。

但是对于下标检查这个比较操作来说,需要程序中所有数组的位置和长度方面的信息,这将占用一些空间。当程序运行时,这些信息必须进行更新,以反映自动和动态分配的数组,这又将占用一定的时间。因此,即使是那些提供了下标检查的编译器通常也会提供一个开关,允许去掉下标检查。

我拿VS做了一下实验,发现Debug mode下是会进行比较严格的下标检查的,比如:

int main()
{
	int a[5] = { 0 };
	a[5] = 1;
}

这段码,文件后缀改.cpp或者.c,都会在编译生成的时候报错。

不过这种的“不那么明显的”操控越界内存的行为则不会报错:

int main()
{
	int a[5] = { 0 };
	int b = a[5];
}

但是!第一种的写法,切换到Release mode之后,就可以正常生成了,仅仅只会有一个警告。这一点也更加印证了编译器在Debug mode下确确实实会多做许多工作,而release mode更能保证程序的运行速度。

譬如这样,在release mode打上break point之后,会发现release mode把没用的东西全部扔掉了,也不会保存变量的临时状态,以至于我想这样观察变量的值都不可以。

五、字符串行为

①补充--在字符中,'\0'代表的就是0,而不是'0'。并且字符中'\xxx'的xxx会被视作八进制来处理!所以:二进制开头0b,十六进制开头0x,八进制开头0

②关于strlen函数--它的行为是不断地读取字符直到遇到'\0',相当于从第一个字符开始,读到一个就长度++,直到遇到'\0',返回长度。因此对于以下这个例子:

int main()
{
    char str[]="Hello";
    int a=strlen(str);
    int b=sizeof(str)/sizeof(str[0]);
}

a的值是5,b的值是6,因为'\0'也在字符数组中算作一个字符,会被sizeof读入,而strlen不会读入'\0'。

③处处注意'\0'的存在!——比如这个例子,最终的打印的字符数要-1,因为会有'\0'的存在。

③一个经典的统计特殊字符的案例:

六、基于上述补充的知识点的面试题详解:

①、指针声明知识点的题目:

跟着红框里海牛桑的读指针声明的步骤,由内到外,由左到右来看即可。

 对于2.(2),步骤:先升级为指针——(*a),指向有十个元素的数组——(*a)[10],每个数组元素的类型是整型——int (*a)[10]。

※char *const a这种类型的指针,一般用在硬件比较多;常用的一般都是const char* a,也就是const *这种一个一个字节读取而且满足只读条件的指针

②指针概念类的题目:

再强调亿遍——"Hello"这个东西,在内存中的字符串常量区里边存储的时候,自动会带一个"\0"!!

③对于传参方式的补充——传输组的时候,最好(char s[],int size)这样,要么就干脆(char s[100])。不过前一种更好一些。注意传char s[100]和char *s在编译器看来是完全相同的,编译器只会知道这是个标识地址的参数,不会知道最多只能读取100个元素之类的。只是给人看起来,写成char s[100]会更加好康一些。也就是说,C语言中根本没有什么所谓的传数组的说法,本质上就是传指针而已

④对于越界问题的补充——越界访问&越界操作一直都不被认为是一个一定引起问题的操作,但是是一定要避免的。因为会导致内存的不可控性

⑤关于strlen:

写str[]的时候,strlen的输出结果是随机的,直到读到'\0',但是'\0'的位置并没有显示指明,所以strlen的值可以理解为随机值。

但是写str[10]的时候,str其他的元素会自动被初始化成'\0',于是strlen就可以正常读取

※⑥结构体对齐类题目: 

宗旨:32位系统内存读取时每次读取4个字节。为了每次读取的有效性,也就是每次读取都能够读到完整的数据而不必和其他的读取相拼凑,往往结构体在内存中的大小是结构体成员中内存占据最大的成员的整数倍。

但是这样的存放方式,可以降低内存的消耗,也是内存优化的一个很重要的方式:

 

如下是对于这种存放方式的解释—— 

⑦指针访问类题目:其实是对于指针的读取方式的考察

(一)

红框处的对a取地址的运算操作,相当于放大了数组的行为:

 b8到bc,加了4,也就是说此时仍然+1这个运算,是针对于a这个指针++;

写了取地址符之后,放大了a数组的行为,此时++则是5个int5个int的++

从a8到bc增加了20: 

于是:对于(int*)(&a+1),先看右边,在a的地址的基础上加了20(5个int),然后再把指针的访问方式,也就是步长修正为了int型的4个字节4个字节走的形式。

经过这样处理之后,ptr指向的地址值相当于是a[5]的地址,ptr的步长是4个字节

所以*(ptr-1)实际上是a[4]的值

(二)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值