六、深入讨论C++数据类型:常量、变量、数组、字符串、指针、函数
看过我写的python语法的小伙伴可能会知道,我们讲python时,从类讲到到魔法方法,再从魔法方法讲到元类,刨根问底儿到最最后的宇宙起始竟然是type!可见type是我们程序员理解问题本质的出发点!同理C++也是。当你以后学习了更多的编程语言和系统开发之后,你就会发现,优秀的编程语言的底层逻辑都是非常相似的。可谓优秀的作品如出一辙,糟糕的东西千奇百怪。因为优秀的成果都是相互借鉴参考不断迭代升级而来的,很难有凭空诞生的好点子。所以C++也是,当然是先有C++,再有python的。也所以一开始我们就讲了C++中的数据类型,但是鉴于最开始大家涉及到的其他的知识点有限,所以讲的数据类型深度也有限,所以本部分是从另外一个角度再聊数据类型。
那话再说回来,那什么是type呢?type就是数据类型。那什么是数据类型?
数据类型就是这个对象的在内存中的存放区域(作用域、链接属性、生存期)、大小、布局(编解码)、存储在该区中的值的范围、可以用于其上的操作集。
存放区域:就是存放地址,内存的地址都是有编号的。就是从哪个内存地址去存储和读取这个对象,也就是存放的首地址是什么。
但是不同的地址还有不同的意义的,因为内存是分区的,不同类型的数据是存放在规定的各自的所属区域的,不同的区域中的内存都是有其地址编号规律的。
那内存为啥要分不同的区域呢?自然是分门别类存储、分门别类管理喽(下图有内存分区的名称)。不同区域的数据的作用域、链接属性、生存期都是不一样的!
比如一个函数内的变量对象,那编译器就会把这个变量放到栈中的,这个变量就是一个局部变量,它的作用域就是代码块的作用域,就是它所在的花括号{}范围内的作用域。它的链接属性就是none,就是没有链接属性,因为在代码编译时还有linker这个步骤(可以回看我的编译原理文章),链接属性有external(外部的)、internal(内部的)、none(无)三种情况,你是局部变量你的链接属性就是none。当花括号的代码执行完毕,这个变量所在的栈内存就随之初始化了,或者说这个变量就消失了,就是它的生存期是动态的、在代码块结束时就自动释放存储空间了。
如果一个对象在函数体外,那编译器就会把这个对象放到静态区或者代码区或者堆中,甚至可能会放到寄存器中,此时你就只能读不能寻址了(具体存放在哪里还是得看这个对象的类型)。那么此时这个对象就是一个全局变量,它的作用域就是文件作用域,就是从它的声明位置开始,到文件的结尾处都是可以访问的。它的链接属性就是external或者是internal,external就是多个文件中声明的、和它同名的标识符都表示的是它本体;internal就是只有在它所在的文件中出现的和它同名的标识符都表示是它本体。这种对象只有当所有文件的代码都执行完毕,它才消失,就是它的生存期是静态的,就是在程序执行期间将一直占据存储空间,直到程序关闭才释放。
也所以我们经常在源代码中会看到比如static、extern等这些关键字,这些关键字都是用来约束这些对象的作用域、链接属性的,从而也就决定了这些对象的生存期。
大小:就是存了几个字节,就是这个对象我要用几个连续的字节来存放;也就是存放的步长。同理,读的时候我就从存放的首地址起,连续读几个字节(步长)就读出这个对象了。比如我们最常见的int类型,那就是从首地址开始连续读4个字节就把这个对象读出来了。那如果是一个函数名对象呢?那也是从函数存储的首地址开始,连续读取函数体长度个字节,就算是加载了这个函数。那函数体长度具体是多少个字节呢?这里面又牵扯到非常多内容,以后我们讲结构体类型时会展示。
布局:就是编解码。为什么要编解码?编码是存的时候要编码,比如我们存个字母a,我们得先把a根据ASCII码转换为十进制的数字,然后把十进制数字再转换为二进制,然后安大端或小端的方式去存储。解码就是我读到的二进制如何再一步步转换成字母a。
存储在该区中的值的范围:比如我规定了一个int类型的整数只能存储4个字节,也就是32位,那这个内存空间能存储的最大数字就是2的32次方-1,这个数字就是整型对象的值的范围,如果你要存一个巨大的数据,你要考虑一下会不会溢出的问题。
可以用于其上的操作集:比如int a;这条语句,就是限定了对象a是int类型,别看就是这么简单的一条语句,其实背后前人帮我们打包了很多很多相关的东西,比如,这就代表对象a可以做加减乘除运算了,那背后就是前人已经帮我们写好如何实现加法、减法、乘法、除法等一系列的函数和类了;当我们敲a++,前人是已经把运算符++的功能给我们实现了,我们现在只需要调用即可,方便我们直接使用了,也避免了重复造轮子的尴尬。再比如对于char类型,背后前人就已经帮我们开发了拼接、查找、替换等函数或者类。再比如int类型有+运算,char类型也有+运算,但二者计算逻辑却大相径庭,那就是背后前人已经对运算符+号给我们分别编写了针对int类型的计算逻辑的函数或者类、以及针对char类型的计算逻辑的函数或者类,当我们使用int + int时可能是默认的,但当我们使用char + char时,这个函数或者类就被重载了。这就是不同数据类型对应不同的操作集的几个简单的例子,辅助我们理解。
-
C++中的对象必须被定义为某种特定的类型 - > 声明对象的类型
C++内置数据类型(built in): 文字常量(literal constant) - > 变量(symbolic variable) - > 对象(object)
C++复合类型(compound type):指针类型、数组类型
C++标准库类型:基本类抽象、vector类。基本类抽象类型(比如字符串、复数)虽然不是基本类型,但它们也是使用C++程序的基础。 -
理解数据类型首先要理解内存,C/C++内存分为:
1、常量
(1)常量的定义和种类
在C++中常量分为两种,文字常量(Literal Constant)和常变量(Constant Variable),但是二者有很大的区别和不同:
文字常量:当代码中出现的数值,比如数值常量1、3.14等、字符常量a、字符串常量(是个数组)、符号常量等,被称为文字常量literal constant。
常变量:当代码中的变量出现const修饰时,这个变量就被转换为常变量了。
由于文字常量是源代码中提供的对象,所以在编译源代码阶段,文字常量就不得不作为二进制可执行指令的一部分,随同指令一起被存放到代码区,也就是我们常说的"硬编码",就是嵌入在二进制指令中的,在程序运行过程中是不可改变的。也所以文字常量可以是数值、也可以是字符、也可以是以数组形式存在的字符串数组、更可以是任意的符号常量。
由于文字常量是指令的一部分,所以文字常量一是不可更改,你不能更改指令吧!二是不可寻址nonaddressable。为什么?因为你取址无非就是两种用途:
- 根据地址修改值
- 引用传递
如果你能取到常量的地址,岂不是顺着地址就给把它内存空间中的值可以改掉了,就是你更改了代码指令,这是不能容忍也不能允许的事情,所以在底层设计上不允许我们对常量取地址,也所以设计者干脆给我们屏蔽掉了对常量的取地址操作,因为没啥意义,也所以说常量值的传递也都是值传递,而非引用传递。
但是常变量又是另外一番逻辑:
常变量是在程序运行中产生的对象,随着程序运行结束也就消失了。所以,常变量分全局常变量和局部常变量,所以常变量存储在数据区(堆、栈、数据段、BSS 段),是可寻址的。
其实常变量本质上仍然是一个变量,只是这个变量被const(类型限定修饰符)限制了。一旦某个对象被const限定后,在程序中任何改变这个值的做法都会导致编译错误。所以这个对象也被称为常量。
文字常量和常变量统称常量,所以二者的共性就是:值不能被改变,就是在定义后就不能修改,也所以在定义时就必须同时初始化。
A:语句A中的int a 表示定义了一个变量,变量名叫a。前面的const关键字表示把对象a转换成一个常量。那么此时就是定义了一个常量a,那就必须得给a一个初始值,20就是a的初始化值。所以这个语句A就是定义并初始化了一个常量a。
同理其他语句。
(2)const关键字
const又称伪关键字,因为它在改变生成代码方面(就是二进制可执行指令方面)是什么也没做。就像类和结构体中public\private关键字一样。这些关键字在生成的指令方面没有任何作用。就是编译后的汇编代码中是没有像const、public、private这样的东西的。也就是说这些关键字仅仅是在编译时,编译器的规范要求。就是当你源代码中有类似const等关键字时,编译器就认为你做了一个不变的承诺,所以后面你源代码中如果有对这个对象试图改变的操作,编译器就不会放过你,编译阶段就会报错,自然也就没有后面的执行操作了。也就是规范你源代码的操作行为的。也就是确保你源代码的行为要规范。
但是在实际的目标代码里(就是汇编代码或者可执行二进制里面)根本不存在这些规范,也所以:在程序运行期间只要你愿意,你是可以通过内存工具修改它的任何一个变量的值的。也所以我们给const叫伪关键字。就是只是规范了你编译阶段源代码的行为,但是在执行阶段,那就管不了,你可以通过工具打破这个规范,或者说绕开这个规范,比如下面的例子:
关于const关键字的相关内容远不止这些,后面我还会专门针对一些常用的关键字详细展开讲。
(3)常变量和指针混搭的各种情况:
这种情况还是比较简单,记得加const就行。
(4)字符常量和字符串常量还是有些不一样、以及字符串和指针混搭:
为了知识体系的完整性,我尽量穷举了,所以这里只展示运行结果,不展开结果的具体原因,所以有不明白的地方,后面讲字符串时都会再继续深讲。
2、变量
(1)上图定义的都是变量。
变量存储在变量区,可以通过程序对其进行读、写和处理。所以变量是可寻址的addressable。也所以每个变量其实都有两个值:数据值(右值)和地址值(左值) ,在C++中,这两个值都是可以被访问的(取址符&)。
每个变量都与一个特定的数据类型相关联,这个类型决定了这个变量的内存大小、布局(编解码)、存储在该区中的值的范围、可以用于其上的操作集。
变量名,就是变量的标识符identifier
(2)变量的数据值和地址值:
数据值,就是在某个内存地址中存储的值,这个值也称为右值rvalue,是are-value的意思,意思就是被读取的值read value。常量和变量都可用做右值(当然啦!)
地址值,就是存储数据的那块内存的地址,这个值也称为左值lvalue,是ell-value的意思,左值的意思就是位置值location value。常量不能用作左值。
比如上图B,变量a同时出现在赋值操作符=的左边和右边。右边表示读取变量a,就是读取变量a的数据值。左边表示是用作写入的,就是减操作的结果12要存储到a的位置值所指向的内存区域中,原来的数据值会被覆盖。
(3)声明、定义、初始化
对象声明declaration,作用是让程序知道该对象的类型和名字。声明不是定义,不会引起内存分配。
上图右边extern int s_Variable; 是声明变量s_Variable的语句。因为变量s_Variable被定义在别的cpp文件中,所以要声明一下即可,不能重复定义的。重复定义编译时会出错。所以关键字extern就表示给编译器打声招呼,你可以到别的文件中找找这个变量的定义。这就是声明。
对象的定义会引起相关内存的分配,上图的C处的变量a、a1、b、c、d就是定义。定义就是指定了变量的类型和标识符。
上图C处的a1是既定义了又初始化了。上图D区的语句都是赋值语句,但由于是第一次赋值所以我们也可以叫做初始化变量。
定义一个对象不一定需要同时也初始化这个对象。就是一个对象被定义了,但它可以是未初始化的uninitialized。未初始化的对象也不是没有值(值可能就是与它相关联的内存区域中的一个随机位串,可能是以前使用的结果),而是它的值未被定义undefined。
(4)静态对象、静态内存分配
上图A语句是我们的编写的代码,这行代码首先是被编译器编译,编译后才可以执行。那当编译语句A时,编译器就分配一个存储区域存放数值100(也就是用值100初始化该区域),并将该区域的地址值与变量名a1相关联。这叫静态内存分配,a1叫静态对象。静态对象的内存分配和释放,都是由编译器自动处理的。
静态对象是有名字的变量,我们可以直接对其进行操作。而动态对象(就是程序员new的对象)是没有名字的变量,需要通过指针间接的对其操作。动态对象的内存分配和释放必须由程序员通过new和delete两个表达式来显式的完成。而且动态对象在内存中是存放在堆上。
(5)变量和指针混搭
3、数组
数组、指针、字符串数组、常量、引用、类、结构体等这些基本类型混搭在一起,就非常难理解,只能一点点梳理。
数组和指针的基础知识点参考我的C系列博文:
【C语言学习笔记】四、指针_使用变量时,除了从内存单元读取外还有什么-CSDN博客
【C语言学习笔记】三、数组-CSDN博客
(1)数组就是,相同类型的变量,按照特定顺序排列在一起的一个集合。 所以:
- 数组中的所有元素是连续放在某一块儿内存区域的。
- 数组中的所有元素我们是无法一下全部查看的(字符串数组除外),只能通过索引或者数组名(因为数组名本身就是指针的包装)来查看。
- 数组名打印出来是这个数组在内存中的第一个元素的地址(字符串数组除外,后面会单独讲字符串数组),所以数组名是指向数组首元素地址的指针。
- 对数组名寻址打印出来是这个数组在内存中的地址(字符串数组单论),所以数组名又是一个指向数组的数组指针。
说明:指向数组首元素地址和指向数组是两码事!前者指针走一步是数组一个元素的内存大小,后者走一步是整个数组的内存大小。这篇博文从汇编码剖析得非常清晰:https://zhuanlan.zhihu.com/p/495724359
所以当我们创建一个数组的时候,编译器不仅要在内存中开辟一块连续的内存空间来存储数组中的各个元素,编译器还创建了一个数组指针来指向这个数组!(字符串数组另说),但是这个数组指针具体存储在内存哪里,这个只有编译器知道,我们无法寻址。主要是我们也无需寻址,底层都打包好了,我们也不需要寻址,只要使用即可。
(2)我们经常用循环来初始化数组、访问数组,就是通过索引来遍历数组:
(3)在堆上和在栈上创建数组的区别:
本部分内容可以搭配着静态内存分配、动态内存分配、以及指针的内容一起理解。讲的是一个事物的不同角度。
(4)array数组
以上我们讲的数组是最最原始的数组,所以会有很多要考虑的地方,比如越界问题等,所以后面人们把数组又包装了包装,现在很多人可能更喜欢用array数组,使用array数组要比原始数组安全得多:
用array数组,虽然要增加点开销,但要方便很多,比如计算数组长度.size即可,还有像边界检查等我们都不需要考虑了。
(5)非字符串数组和指针混搭:
再把后面的两个例子写详细点:
(6)非字符串数组和字符串数组对比:
(7)字符串、常量、数组、指针之间的纠纷终极梳理:
(8)两个小案例:
案例1:用户在键盘上输入字符,把用户输入的字符都存储到一个字符串数组里面。这个例子中我设置的字符串数组长度只有16,所以超过的部分就舍弃了,不保存。
案例2:用户输入不多于5个整数,把这些整数保存到一个数组里面,并计算这些个数字的和。如何小于5个整数,就以0为终止标志。
4、字符串
上面从常量的角度讲过字符串、从数组的角度也讲过字符串,以及字符串和指针的混搭。所以字符串都反反复复好多遍了。但是字符串非常重要也非常难理解,所以这里我们从字符串本身出发再深度聊聊字符串。
(1)什么是字符串
C语言是没有字符串类型的。因为字符串本质上就是一个字符(A)接着一个字符,是一组字符,所以是不需要单独设置字符串类型的。所以在C中存放字符串的方式:一是字符串常量,就是用双引号引起来的一连串字符;二是用一个数组来存储字符串,就是字符串数组。
A:字符就是一些符号,可以是字母、也可以是数字、也可以是一些其他符号(比如逗号句号大于号小于号等等),总之都是文本的形式,可以是一个单词、也可以是一句话、一段文字、一篇文章等等。再比如,我们编程时,coding的这些代码也是文本的形式。一堆字符在一起就是字符串了。
C++中的字符数据类型叫char,是character的缩写。是一个字节的内存。当我们设置一个char类型指针时,就可以对字符做运算了。如果你想分配1k的内存,那你就分配1024个char就是1k的内存了。
C++默认是依据Ascii编码规则对字符进行编码的。Ascii是一种字符编码系统。当然,Ascii又可以扩展为UTF-8、UTF-16、UTF-32等等,还有wide string(宽字符串),就是一个字符有2个字节或者3个字节或者4个字节的等等。
在C++中处理字符,是一个字符一个字节,这样的处理方式。所以,这就意味着,能处理的字符只有2的8次方,256个字符。也所以为什么我们还有UTF-16、UTF-32等编码系统。但是C++是基础语言,不使用其他任何库,只是原始数据类型,就是一个char一个字节!普通字符串就是普通字符组成的,普通字符就是一个字符一个字节!
如果我们想使用汉语,那就做不到一个字符一个字节,因为汉字远远不止256个汉字。所以此时我们就不得不使用其他类型的字符串,比如utf16、utf32、GBK等编码汉字字符。
(2)C++提供了两种字符串的表示:C风格的字符串和标准C++引入的string类类型。
下面先介绍C风格的字符串,同时也是对前面所有和字符串相关的内容的一个全面总结:
C风格的字符串起源于C语言,并在C++中继续得到支持。系统在内部把字符串常量存储在一个字符串数组中,然后,通过一个char*类型的字符指针指向该数组中的第一个元素,用指针的算术运算来遍历数组,进而操纵这个字符串。标准C库为操纵C风格的字符串还提供了一组函数,比如返回字符串长度、比较两个字符串是否相等、把第二个字符串拷贝到第一个字符串中等等,我们要想使用这些函数,就得包含C头文件:#include <cstring> 这里就不再演示了,想了解的同学看我的C语言中的相关博文。
但是,由于C风格的字符串实在是太底层了,又是对指针进行操纵,所以初学者经常会出错,比如忘记考虑空字符、或者指针指向的地址已经超出数组边界了等等问题。所以C++标准库提供了字符串类抽象的一个公共实现。
A:我们要想使用string类型,必须先包含头文件:#include <string>
B:上图我们之所以可以打印出s,也是因为在string头文件中有<<操作符的重载版本,就是允许我们发送字符串到流中的重载版本,我们才可以打印出s.
C:两个字符串的底层是两个地址,两个地址怎么能相加呢?!不能!但是string类中有+=操作符的重载函数,所以可以这样写。
尤其是上右图中的string数组更是简单好用,再也不用去想数组了,因为数组本身就错综复杂,有C风格的数组还有array数组,还有vector动态数组等等,基础不是很牢的同学很容易迷糊。
(3)对于字符串对象,能引用就别拷贝
下面这个案例是把字符串当作参数传递给一个函数:
由于字符串是存放在常量区,而且是不可改变的。所以将字符串对象作为参数传递给函数PrintString时(A处),这个过程其实是先拷贝这个字符串参数,然后再传递给函数,就是函数PrintString接受的参数a是一个拷贝的name。
但是,字符串的拷贝是非常慢的。因为必须要动态地在堆上heap分配一个全新的char数组来存储我们已经得到的完全相同的文本,这是非常慢的,也是字符串对象的一个短板。所以,当你要给函数传递一个字符串对象作为参数时,而且你也是只是只读一下这个字符串而已(所以右C要注销,你必须只读一下),此时你可以通过常量引用(B处) 来传递,而不是拷贝传递(A)。
引用传递会快很多,但是代价就是你不能修改这个参数了,所以右C要给注销掉,不然就会报错。
而拷贝传递A虽然低效,好处就是可以修改参数a,左C处就是修改了字符串a。但是你要清楚,左C处修改的a可不是字符串常量name!字符串常量name永远是const常量,a是对name的一份拷贝,修改的是这个拷贝,所以D处打印的结果还是const name。
通过这个案例我们也可以依稀体会到为啥字符串每次都这么特殊!因为它在常量区,而且拷贝的代价还很高。
(4)继续深探字符串:字符串字面量
估计在介绍C风格字符串中的那张图,大家都已经懵了吧。一方面说字符串是存储在内存的只读区域、不可寻址,另一方&数组名或者&指针名都得到了一个内存地址,这个地址是字符串存放的区域吗?为什么有时可修改有时又不可修改?!为什么这么奇特?本部分从字符串字面量讲起,一步步解开答案。
字符串字面量是在双引号之间的一串字符,如下图的"nihao,liyuanyuan"、"cherno"。这一串字符没有名字,它字面的意思就是它自己。那这一串字符究竟是什么?是数组?是常量?是指针?
细心的同学会发现:在D处,"lyy"也是一个const char[4]数组,为啥一个非const的char ss[]数组也能承接?而且说好的"lyy"是不能被修改的,ss竟然还修改了其中的'l'元素?!为了弄清这个问题我们从汇编文件和二进制可执行文件里找找答案:
从汇编文件和.exe文件中,我们都可以看到所有的字符串字面量永远都保存在内存的只读区,无论何种方式也永远不能被修改。至于字符串数组ss为啥可以承接"lyy"并修改了L,那是因为数组ss的值"lyy"是拷贝了常量区的"lyy",并存储到栈上了,所以ss修改的L是栈上的"lyy"而非常量区的"lyy"。从上图的汇编码和二进制码都可以看到常量区的"lyy"还是"lyy"而不是"Lyy","Lyy"随着main函数执行完毕就已经被释放了,不存在了。但"lyy"是嵌入在二进制指令中了,也是不可修改的。
那什么情况使用的是只读区的字符串?什么时候是copy的字符串?这就由编译器决定了。当编译器读完你的源代码,如果你承诺的是const,而且中间也确实只是读读,没有修改,那就汇编成字符串字面量;如果编译器发现你既没有const承诺,而且中间也修改了字符串中的元素,那编译时就会自动copy一份。
可见:字符串常量究竟是个什么类型的对象?答案是,在不同情况下,它成为不同的类型。
当源代码中出现字符串常量时,它首先是一个被存储在代码区(只读区)的一个不可变字符串数组。
当在源代码中,把它赋值给一个const char[]数组,而且也遵守承诺不修改这个字符串,那此时这个字符串就是一个不可修改、不可寻址的数组。
当在源代码中,把它赋值给一个const char*指针,而且也遵守不使用指针修改这个字符串,那此时这个字符串还是一个不可修改、不可寻址的数组。
当在源代码中,把它赋值给一个char[]数组时,如果这个数组没有修改这个字符串,那编译器还是把这个字符串以不可变数组形式放在可读区;如果这个数组后面修改了这个字符串中元素,那编译器就会在栈区复制一份副本,此时这个副本就是一个可修改、可寻址的普通字符串数组。当然源代码修改的也是修改副本的数据。
(5)字符串的其他知识点
事实上是,C++的标准库有一个模板类叫BasicString,sting类是BasicString类的模板版本,就是将BasicString模板的参数设置为char,因为char类型就是每个字符背后的实际类型。所以如果你设置为Wstring,就是宽字符串,就是一个字符占2个字节。
事实上,string也是一个类,所以它有构造函数,构造函数接受char*或者const char*为参数
最后,我们从内存中再看看终止符\0:00的ASCII码是 Null char (空字符)
5、指针
前面也是一直已经在用指针了,还详细梳理了指针和常量、指针和变量、指针和数组、指针和字符串之间的书写规范,下图是以指针为主角,对上面知识点的一个小结:
上图展示的都是运行结果,其中最迷惑的就是数组名这个对象了。那数组名到底是个什么?网上很多资料众说纷纭。但是无论怎么解释,数组名底层毫无疑问就是对指针的再次打包加工后的结果,当然还有编译器的转换功劳。
网上的各种资料真是良莠不齐,比如有的人说是数组名底层就是一个指针,但是从运行结果来看,它又像是一个引用,而引用背后的逻辑还是对指针的包装。但是经常有人说引用只是一个别名,引用本身不占用内存空间。错!真是不对!引用本身就是使用指针实现的,怎么可能不占用内存,只是实现上让开发人员感觉是一个别名的样子。至于为什么呈现这种效果,还是因为编译器在背后也做了很多工作,因为编译器的作用就是充当程序员与cpu之间的一个翻译,一方面要更符合程序员的思维,一方面要cpu可执行,所以有一部分真相是只有编译器知道的。就如上图的四,如果说c是一个指针,那c就是数组首元素的地址,那&c就是指针c自己的地址,但是这两个地址返回值相同!同一个内存地址既可以存放首元素1还可以存放一个地址数据?!当然不可能。那你说c是一个引用吧,那c的返回值就应该是1而不是1的地址。所以这部分真相的关键就在编译器。还有人说是一个数组指针,这个说法我们后面讲数组指针时再深挖。有一篇博文说得非常好,他是这样说的:“总而言之,引用就是引用,是这种概念,它为方便程序员使用,和方便汇编工具优化而产生。汇编怎么实现和优化是汇编的事,至于出了什么违反该概念的结果,是汇编的错,而不是定义的错,不要本末倒置。你可以通过汇编来了解编译器怎样实现引用,但是引用却不应该用汇编来解释,它只是一个概念”。所以我们先记住这样的运行结果吧。下面再补充几个指针特有的变异,加深对指针的理解:
(1)指针数组
就是一个数组,但是数组的每个元素都是一个指针变量:
像上图中的这种情况,当你有n个字符串时,你就只能用指针数组来组织这些字符串。如果你用一个普通的字符串数组:const char a[] = {"nihao","hi", "nihao,liyuanyuan"}这样初始化,编译器就会报错,就没法初始化。因为数组的本质数组中的每个元素都是数据类型、数据长度都一样的,才能在一片连续的内存中创建这个数组。当每个元素的类型或者长度有一个不一样,那就没法创建数组。此时指针数组就完美解决了这个尴尬。
(2)数组指针
前面也一直提到数组指针,这里就开揭数组指针的面目。
只要你能搞清楚什么是“指向数组首元素地址” 和 “指向整个数组地址”这两个概念,上面的运行结果都应该是在你的预料之中。如果还是不清楚的同学,看这篇文章https://zhuanlan.zhihu.com/p/495724359
此时会有一个疑问为什么要这么设计?普通指针指向一个数组的做法就很香,干嘛要用数组指针?其实答案就是指针的定义,还是需要你对指针本身的深刻理解,其实根本也没有啥创新点,还是指针的应用。当我们定义一个指针 int* p时,表示指针p这个变量中存储的地址中存储的数据是int类型的,也就是我只要以这个地址为起点,依次读取4个字节即可。那此时如果我们的数组是一个高维数组,你在一个int一个int的读取岂不是效率低下。打个比喻:当你数一筐苹果时,你可以123456....一个个数,但是让你数一卡车苹果,你是不是就需要按2468数或者按10 20 30 40 ...这样的数。 所以当我们遇到高维数据时,我们就需要指针不再是一个int一个int的指,而是一个维度一个维度的指,所以此时我们就需要数组指针。也所以数组指针本质上和普通指针是没有差别的,也是需要在内存中开辟8个字节存储这个指针本身的,而且存储的内容也是一个地址,只不过从这个地址上读取数据时,是按照特定长度(n-1维度的数据的长度)来读取的。下面我们从例子来体会:
如上图:我们先创建一个二维数组a。首先你要知道二维数组也是一维形式存储的:
那数组名a其实就是一个一维数组指针。如果我们要创建一个二维数组,那编译器除了会开辟一块连续的内存存储数组中的数据,还会再创建一个一维数组指针,就是再在内存某处开辟一个连续的8个字节的空间,存放一个数组指针,也就是在这个空间中存放一个地址数据,这个地址数据就是数组{1,2,3,4}存放的首地址,步长是16。说明:{1,2,3,4}在内存中也是连续存放的,具体存放地址见上图绿色小标号。
但是,数组名a本身到底存在内存哪里?我们程序员是不知道的,也无法寻址的,只有编译器自己知道,它是和创建数组时就一起创建了。虽然&a返回的也是一个地址,但这个地址一定不是数组名a的真实内存地址,因为这个地址里面存的是数据1呀,不可能同时存数据1再存地址数据的,所以到底a本身存在哪里,只有编译器知道,我们程序员是不需要知道的,都被编译器打包成黑盒了。
简言之,数组名a也是一个指针,具体这个例子是一个一维数组指针,a的值是{1,2,3,4}的首地址78(见A处),指针a的步长是16。更重要的是,a指向的地址中存放的还是一个指针,这个指针再指向78号内存。也就是说a还是一个指针的指针!是一个二级指针!这样做的好处就是,我们既可以一个int一个int的指数组中的数据,也可以一个维度一个维度的指数组中的数据,效率一下子就有了。
所以,打印a返回的就是{1,2,3,4}的首地址78(见A处)
所以,解引用*a返回的还是一个指针,这个指针指向数组{1,2,3,4}中的首元素地址78(见B处)
所以,解引用**a返回的就是存储在78这个内存地址里面的数值1(见C处)
另外,由于a是一个一维数组指针,也就是a存的是一个地址,而地址是可以运算的,而且如果要读取a指向的地址中数据,4x4=16,一次是按16个字节来读取的,所以,也就是a+1(见A1)就是地址88, a+2(见A2)就是地址98。
所以同理a,解引用*(a+1)(见E)就是数组{5,6,7,8}的首元素地址, *(a+2)(见F)就是数组{9,10,11,12}的首元素地址。解引用**(a+1)(见G)才是5, *(a+2)(见H)才是9。
最后,&a(见D)就是指向整个数组的指针,那它的步长就是16+16+16=48个字节,所以&a+1(见D1)的运算结果就是地址a8(已经越界了啊), &a+2(见D2)的运算结果就是地址d8(也越界了,所以千万别随意赋值了)
同理,所以*(&a+1)(见I)就是地址a8-b7这16个字节的内存首地址;**(&a+1)(见K)就是地址a8-ab这4个字节的首地址;***(&a+1)(见M)就是a8-ab中存储的数值,由于a8-ab内存没有初始化,所以数值有点怪。
同理,所以*(&a+2)(见J)就是地址d8-e7这16个字节的内存的首地址;**(&a+2)(见L)就是地址d8-db这4个字节的首地址;***(&a+2)(见N)就是d8-db中存储的数值,d8-db内存也是没有初始化,所以数值有点怪。
上述都理解了,就不难理解PQRSTUVWXY了。下标索引(例如[0]、[1]、[1][1]等)是一种语法糖,它的语法就是先进行地址运算、然后再解引用:
a是指向{1,2,3,4}的数组指针(从78-87,步长是16),同时a也是一个指向指针的指针。所以,a[0](见P)的实现过程就是:先计算(78-87)+0,还是78,然后解引用,解引用后就是一个普通指针(78-7b,指向首元素1的指针)。所以a[0]就是存放1的地址的普通指针,所以a[0]指针的返回值是78这个地址(步长是78-7b);也所以*a[0](见Q)的返回值就是数值1。
同理,a[0][0](见R)就是在a[0]的基础上(a[0]是存放数据1的地址的普通指针,值是78,步长是4个字节),先+0,还是78,再解引用就是数据1了,所以a[0][0]=1。
同理,a[0][1](见S)就是在a[0]的基础上(a[0]是存放数据1的地址的普通指针,值是78,步长是4个字节),先+1,就是78+4=7c,再解引用就是数据2了,所以a[0][1]=2。
同理,a[0][2](见T)就是在a[0]的基础上(a[0]是存放数据1的地址的普通指针,值是78,步长是4个字节),先+2,就是78+8=80,再解引用就是数据3了,所以a[0][2]=3。
同理,a[1](见U)就是先计算a+1 = 78+16(a的步长) = 88,再解引用就是存储地址88的普通指针,所以,a[1]=地址88,步长就是4了;也所以*a[1](见V)就是5。
a[1][0](见W)就是在a[1]的基础上(a[1]是存放数据5的地址的普通指针,值是88),先+0,还是88,再解引用就是数据5了,所以a[1][0]=5。
a[1][1](见X)就是在a[1]的基础上(a[1]是存放数据5的地址的普通指针,值是88),先+1,就是+4个字节,就是8c,再解引用就是数据6了,所以a[1][1]=6。
a[1][2](见Y)就是在a[1]的基础上(a[1]是存放数据5的地址的普通指针,值是88),先+2,就是+8个字节,就是90,再解引用就是数据7了,所以a[1][2]=7。
前面说了数组名a是一个数组指针,但是这是创建数组是编译器创建的,a和&a的结果都是数组首元素的地址,所以数组名自身的地址我们没法寻址,只有编译器自己知道。
但是,上图的一维数组指针p、二维数组指针pp,都是我们自己创建的对象,所以都是可以寻址的。&p和&pp都是一个占用8个字节的指针对象。
其次,指针p是一个一维指针,所以对p初始化赋值时,只能把数组名a赋值给p,因为a的类型也是一个一维指针类型,所以类型相匹配,是可以赋值的。同理指针pp是一个二维数组指针,所以pp初始化时就必须是&a了。原因是:前面我们说了&a就是一个指向整个数组的指针,整个数组就是一个二维数组,所以pp的类型和&a的类型是匹配的,所以只能用&a赋值给pp。
最后,p就和a等效,pp就和&a等效,所以p和pp的使用方法就是a和&a的使用方法一致。就不再重复写了。
最后一个同理:一维数组、二维数组、三维数组、高维数组。。。。的组织结构都同理。
上述底层逻辑都了然于胸了,那我们现在实际运用起来,用循环读取或者更改这个二维数组:
上面的例子中的对象a、p、pp都可以解引用或者语法糖[]来访问数组中的元素。所以C++语言是一个非常灵活的编程语言,它的底层逻辑都非常非常简单,也正是这种简单才排列组合出花样多变的灵活写法,也是这门语言的难点所在。所以C++是一门既简单又超难的语言。简单是底层简单,超难是它灵活多变了,各种花式操作,非常烧脑。
(3)void指针
void是无类型的意思。void指针是一个指向的地址中的数据是一个无类型的数据的指针。
void指针又称通用指针,换言之,它就是可以指向任意类型的数据。而且,任何数据类型的指针都可以赋值给void指针。
void指针不需要提前声明类型,而且还可以通过其他有数据类型的指针的赋值,void指针就可以指向任何数据类型的数据了。
void指针是不能对其指向的数据进行指针解引用取值的,因为无数据类型的指针不知道数据类型嘛,就是不知道一次要读几个字节,就是不知道数据宽度,所以就无法解引用!但是我们可以把void指针强制转化成一个有数据类型的指针,此时这个指针就可以接引用了。
(4)野指针和NULL指针
野指针是没有初始化的指针。就是还没有给指针赋值,此时这个指针的值就是内存中原来的数据,一旦你解引用这个指针,并修改指向的地址中的数据时,很可能引发意想不到的灾难。
NULL指针也称空指针,但它不是指向空,而是指向了0地址。在大部分的操作系统种,地址0通常是一个不被使用的地址,所以如果一个指针指向NULL,就是指向0地址,就认为这个指针不指向任何东西。
当你还不清楚要将指针初始化成哪个地址的时候,你就把它初始化成NULL。在对指针进行解引用的时候,先检查给指针是否是NULL。在编写大型程序时,这样做可以节省大量的调试时间。
6、函数
到函数就不再是底层的东西了,很多就非常容易理解了,所以本部分也不再讲什么函数,直接上一些小案例:
最后,以两个排序的小程序结束函数部分:
快速排序是递归操作的典型案例,如果不是太理解看我的博文:【C语言学习笔记】五、函数-CSDN博客 中的最后一个案例,里面有详细分析。
本部分是深刻理解C++数据类型的基础,也是我悟了很久、查了很多资料的总结,喜欢点赞+收藏哦。。。