(二)羽夏看C语言——容器
写在前面
此系列是本人一个字一个字码出来的,包括示例和实验截图。本人非计算机专业,可能对本教程涉及的事物没有了解的足够深入,如有错误,欢迎批评指正。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我。
你如果是从中间插过来看的,请仔细阅读 (一)羽夏看C语言——简述 ,从而方便学习本教程。
容器指的是什么
说到容器,你可能会联想到日常生活中家里的用喝水的杯子
;如果接触过计算机的人,可能会想到高大上的东西,比如应用广泛的虚拟化容器Docker
。无论联想到的是什么,它们具有同一个属性——装东西。
计算机运作的数据需要容器,比如内存或者硬盘。在编程语言层面,它具有的容器就形式各样:变量
、常量
、数组
、结构体
、共用体
等等。在汇编层面,它具有的容器有寄存器
和内存
(这个内存和计算机的内存不是一个东西,通常来说计算机的内存指内存条,此处含义为内存地址空间,请自行科普)。此篇将C语言层面的那些能够存储数据的常见容器
与汇编
逐一联系起来。
变量
In computer programming, a variable or scalar is a storage location (identified by a memory address) paired with an associated symbolic name, which contains some known or unknown quantity of information referred to as a value; or in easy terms, a variable is a container for a particular type of data (like integer, float, String and etc...). ——《维基百科》
英文看不懂,那就翻译一下:在计算机编程中,变量或标量是一个存储位置(由内存地址标识),与一个相关符号名配对,其中包含一些已知或未知数量的信息,称为值;或者简单地说:
变量是特定类型数据(如整数、浮点、字符串等)的容器
变量是什么,对于编程语言来说,变量就是一个特定类型数据的容器
,正如我在文本章开篇说明的。对于CPU来说,目前它具有以下容器:
容器名称 | 大小 |
---|---|
BYTE | 1个字节 |
WORD | 2个字节 |
DWORD | 4个字节 |
FWORD | 6个字节 |
QWORD | 8个字节 |
然而对于32位CPU,它就没有比DWORD
还大的容器了,具体原因请到汇编进行学习查看(需要额外声明,当你用x32dbg调试器随便打开一个程序,发现有大于等于8个字节的容器,那是因为有Intel
的CPU
集成了FPU
,它是专门用于处理浮点运算
的寄存器,不特殊说明仅指普通CPU寄存器)。
对于C语言来说,它具有以下容器:
容器名称 | 大小 |
---|---|
char | 1个字节 |
short | 2个字节 |
int | 4个字节 |
long | 4个字节 |
float | 4个字节 |
double | 8个字节 |
学习C语言,很多初学者学完可能都会有的误区:认为char类型是用来存储字符的,short是用来存储短整数类型 诸如此类的印象。如果这样认识变量,就太肤浅了,你就没学会C语言,变量的本质是容器,是用来组织数据的方式。char
类型不是字符
类型,而是字节
类型(能够装1个字节数据的容器,字节类型是我的说法)。其他的基础变量类型以此类推。
C语言有byte类型,前提包含"Windows.h"头文件,你可以查看它的定义,你会发现如下代码:
typedef unsigned char byte;
可以下一条结论:字节类型就是无符号的
char
类型,它也是字节类型。
既然说到符号和无符号,它们到底有什么不同。前面说char
和unsigned char
都是我所谓的字节
类型。从内存或者寄存器存储的视角来看,它只是显示方式的不同。举个例子,如果一个数,在一个字节大小的容器中,存储0xF4
这一个数(这个是16进制的写法,如果不会,可参考我的下一篇文章)。如果用有符号显示,它是-12
,如果用无符号的显示,它是244
。
铺垫了这么多,是时候打开VS,来说明变量和汇编的关系。我们新建一个控制台工程,输入以下代码以供测试:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
char ch = 1;
short s = 2;
int i = 3;
int unsigned ui=0;
long l = 4;
float f = 5;
double d = 6;
system("pause");
return 0;
}
我们在main函数
头部下一个断点,开始调试,切换到汇编模式,你会看到如下图所示的结果:
图中汇编代码看不懂的同志,请自行补缺。
指针
C语言的精髓是指针,我相信不少人会有所耳闻。很多初学者把指针神话了,甚至僵硬化使用,就是因为对变量是容器
的本质没有理解到位。指针也是变量,只不过有一点点特殊,通常用来存放地址编号罢了。
下面我会给出一段代码,请回答注释当中的问题,看看你学的指针到底怎么样,也看看你对我所述的学习情况。
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
int i = 65534;
int* pi = (int *)5;//这样对吗?
unsigned char* pch = (unsigned char*)&i;
printf_s("pch指向的地址存储的值:%d\n", *pch);//这样对吗?*pch的值到底是多少?
system("pause");
return 0;
}
先别着急检验,我先科普一下小端存储
再继续:
The order of digits in a computer that is the opposite of how humans view numbers. The digits on the left are less in value than the digits on the right. ——《维基百科》
举个例子,一个用十六进制表示的32位数据:0x12345678,存放在存储字长是32位的存储单元中,按低字节到高字节的存储顺序为0x78、0x56、0x34和0x12,通常的CPU采用小端存储,但也有大端存储的,请自行搜索。
答案将在下一篇文章进行揭晓。
以上只是拿一维指针进行介绍,以下拿多维指针继续介绍:
#include <iostream>
using namespace std;
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
//并删除 using namespace std;
//将 cout 改为 printf_s(可以 printf,但微软编译器编译会报错,自行科普)
int main()
{
int* pi1;
int** pi2;
char* pch1;
char** pch2;
float*** pf3;
cout << "以上指针的大小:" << endl
<< sizeof(pi1) << endl
<< sizeof(pi2) << endl
<< sizeof(pch1) << endl
<< sizeof(pch2) << endl
<< sizeof(pf3) << endl;
cout << "以上指针取值一次的大小:" << endl
<< sizeof(*pi1) << endl
<< sizeof(*pi2) << endl
<< sizeof(*pch1) << endl
<< sizeof(*pch2) << endl
<< sizeof(*pf3) << endl;
cout << "对能再取值的指针进一步取值的大小:" << endl
<< sizeof(**pch2) << endl
<< sizeof(**pf3) << endl;
system("pause");
return 0;
}
这次不必看汇编代码了,看看结果:
以上指针的大小:
4
4
4
4
4
以上指针取值一次的大小:
4
4
1
4
4
对能再取值的指针进一步取值的大小:
1
4
请按任意键继续. . .
我们可以下如下结论:所有的指针都是一个大小,为4个字节(32位)。
常量
In computer programming, a constant is a value that should not be altered by the program during normal execution, i.e., the value is constant.When associated with an identifier, a constant is said to be "named", although the terms "constant" and "named constant" are often used interchangeably. This is contrasted with a variable, which is an identifier with a value that can be changed during normal execution. ——《维基百科》
在计算机编程中,常量是程序在正常执行期间不应更改的值,即该值为常量当与标识符关联时,常量被称为“命名”,尽管术语“常量”和“命名常量”经常互换使用。这与变量不同,变量是一个标识符,其值可以在正常执行期间更改。
如上说明了常量是什么和与变量的区别。然而不幸的是,在汇编层面,它们本质是一个东西。我们将用相同的方式用如下代码进行验证:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
int a = 5;
const int b = 5;
system("pause");
return 0;
}
如下就是验证结果:
既然常量和变量本质是一样的,常量也是可以被修改的,那么我们用以下代码进行验证:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
int a = 5;
const int b = 5;
printf_s("b的旧值:%d\n", b);
int* pb = (int*)&b;
*pb = 10;
printf_s("b的新值:%d\n", b);
system("pause");
return 0;
}
结果运行后你发现,与预想的根本不一致:
b的旧值:5
b的新值:5
请按任意键继续. . .
你到此可能怀疑我说的是有问题的,认为常量是无法改变的,其实它已经被改了,请看一下局部变量
的b
的值,它已经是10
了,如下图。
那么,是什么原因导致更改后的值仍然是5
呢,看汇编你就会明白了,这都是编译器的把戏,如下图:
你可以看到,编译器编译好后调用此函数时压根就没有把变量b的地址的内容
放入堆栈中,而是直接将5
压入堆栈,所以导致以上“奇怪”的问题。
局部变量与全局变量
学过C语言的同志,应该都知道局部变量和全局变量的区别和作用域。但是,为什么全局变量每个函数都可以到处用,而局部变量不行呢?让我们从汇编层面来看看是为什么。
先准备如下代码以供实验:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int quanju = 10;
int main()
{
int jubu = 0;
jubu = quanju + 8;
system("pause");
return 0;
}
然后我们看一下汇编,你会看到如下图所示结果(关键部分):
从汇编代码我们很容易看出,局部变量被翻译为堆栈中的一个“临时地址”,这个是由于ebp寻址提栈提供的缓冲区,函数结束后会被平栈,通过普通方式无法使用该值(如果不懂的话,后面将会有一篇文章用来讲述函数的)。而全局变量直接是一个写死的地址,编译完一个程序后,该地址不会发生变化,这就是所谓的全局变量每个函数随意使用,而局部变量不行的原因。
公共变量和私有变量 ❗
公共变量和私有变量是什么定义我就不详细描述了,就是字面意思。我们做一个实验进行验证一下:
#include <iostream>
struct MyStruct
{
private:
int hide = 10;
public:
int show = 20;
};
int main()
{
MyStruct stru;
int h = stru.hide;
int s = stru.show;
system("pause");
return 0;
}
有些眼尖的朋友一眼就能看出,这个代码是编译不过去的,因为hide
是MyStruct
的私有全局变量,不能访问。但既然是全局变量,就是一个地址,难道就不能访问吗,答案是能够访问,需要一点手段——指针。我们来看下代码:
#include <iostream>
struct MyStruct
{
private:
int hide = 10;
public:
int show = 20;
};
int main()
{
MyStruct stru;
int* ph = (int*)&stru;
int s = stru.show;
printf_s("hide: %d\nshow: %d\n", *ph, s);
system("pause");
return 0;
}
你将会得到如下图所示结果:
这个是不是巧合呢,让我们看一看汇编代码:
如果你不看后面的文章,你可能不能完全弄明白,我简单说明一下。第一个图的lea ecx,[ebp-10h]
汇编指令就是传说的this
指针,指向该结构体的地址。下一句的call
就是调用构造函数,虽然我没有写构造函数,但它默认会有一个无参的构造函数(你可能会犯嘀咕,这明显不是类的特征吗?是的,没错,在C语言中,类和结构体是一个东西,没有任何区别,但是通常会把只有数据的称之为结构体,还有功能函数的称之为类)。说了这些,第二张图片也就能看明白了。如果不明白可以在讨论区留言。
数组
最经常用的数组,当属字符串了,数组是最常用的数据组织方式之一。先从最简单的一维数组来看看数组和汇编的关系。我们先用如下代码进行实验:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
int a[] = { 1,2,3,4,5,6 };
int a0 = a[0];
int a5 = a[5];
system("pause");
return 0;
}
然后查看汇编,得到的结果如下:
你可能没见过imul指令,这个是有符号乘法:imul ecx,eax,0
用数学表达式来写的话就是ecx = eax * 0
,以此类推。其他的关于一维数组我就不过多介绍了。
接下来我们看看更高维数的数组,先以最简单的二维数组试刀,简单修改一下原来的代码:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
int a[][2] = { 1,2,3, 4,5,6 };
int a0 = a[0][0];
int a5 = a[1][1];
system("pause");
return 0;
}
然后看一下反汇编:
很多初学者学习二维数组的时候,都是用画表格的形式:
索引 | 0 | 1 |
---|---|---|
0 | 1 | 2 |
1 | 3 | 4 |
2 | 5 | 6 |
这样的方式虽然直观好理解,但会带来一个误区,仿佛内存也是这样存储二维数组的。但是,如果是三维数组甚至更高维数的呢?在计算机中,数组是沿着线性地址顺序存储的,无论是多少维。 上面图示的汇编代码就体现出这个特性,本人不多论述。
二维数组都试了试,再来个三维的加深印象:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
int a[][3][2] = { 1,2,3,4,5,6,7,8,9,10,11,12 };
int ia = a[1][1][1];
system("pause");
return 0;
}
汇编代码如下图所示:
正确的内存存储示意图:
有了这个示意图,是不是更好理解了多维数组。
数组与指针
说到指针和数组的关系,我们看一下代码:
#include <iostream>
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
int main()
{
int a[] = { 1,2,3,4,5,6 };
int* p = a;
printf_s("%d,%d\n", a[2], p[2]);
system("pause");
return 0;
}
汇编代码如下:
仔细观察发现,它们取值方式几乎差不多。虽然可以说数组和指针获取值本质上几乎差不多,但数组一旦定义,在程序的生命周期就不能随意改变。指针是随意的,想指哪就指哪。
结构体 ❓
因为这篇文章只是介绍容器
,故我们接下来继续介绍C的结构体
。为什么这么说呢,是因为C
不支持带函数的结构体,而C++
可以。
对于此类的结构体的讨论,请用以下的代码做实验,在做这个实验之前,请思考一下它的输出结果是多少:
#include <iostream>
using namespace std;
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
//将 cout 改为 printf_s(可以 printf,但微软编译器编译会报错,自行科普)
struct MyStruct
{
int i;
char ch;
char ch1;
int i1;
double d;
};
int main()
{
cout << sizeof(MyStruct) << endl;
system("pause");
return 0;
}
你可能会惊奇的发现,输出的结果为24
,到底是为什么呢,不应该是18
个字节吗?这就是因为字节对齐
的缘故。
对于32位的CPU,它最擅长一次操作4个字节的容器。为了提高性能,就必须牺牲一些东西,那就是空间,即所谓的拿空间换时间
的操作,不满4个字节按4个字节计算。
当然,你也可以强制它一个字节接一个字节的对齐,在定义的结构体之前使用##pragma pack(1)
就可实现,你可以添加后重新编译查看结果。
共用体
共用体,简单来说。就是一个地址多个别名,举个简单的例子就能明白:
#include <iostream>
using namespace std;
//如果是C,请自行将头文件包含改为 stdio.h 和 stdlib.h
//将 cout 改为 printf_s(可以 printf,但微软编译器编译会报错,自行科普)
union MyUnion
{
int a;
int b;
int c;
unsigned char ch;
};
int main()
{
cout << sizeof(MyUnion) << endl;
MyUnion test; //如果是C,请在本行前添加 union 关键字
test.a = 0xfffe;
printf_s("a:%d,b:%d,c:%d,ch:%d\n", test.a, test.b, test.c, test.ch);
system("pause");
return 0;
}
反汇编结果:
输出结果:
4
a:65534,b:65534,c:65534,ch:254
请按任意键继续. . .
由此可看出:共用体的所有变量(更合理的说是别名),都共用一个地址。
编译器是这么做的,但是实际上C/C++
编译器都有一套标准,这里会导致未定义行为。我在这里描述一下:如果共用体有多个成员,从一个成员写入,紧接着从另一个成员读出这个是未定义的,只明确说明了匿名共用体的所有成员的首地址一定相等。
下一篇
本文来自博客园,作者:寂静的羽夏 ,一个热爱计算机技术的菜鸟
转载请注明原文链接:https://www.cnblogs.com/wingsummer/p/15200343.html