基本类型在内存中的存储

此博客原文地址:https://www.cnblogs.com/BobHuang/p/12539317.html
我们的数据是存储在内存上的,我们定义一个变量他就会在内存上对应一小块的地址空间,当然你可以认为他申请了一个房子。char是最小的房子,只有1B(1个字节),int就不一样了,他有4B,能表示范围也更大。可是这个究竟是什么意思呢,他有没有浪费啊。接下来我们就可以看看不同类型在内存中的存储。本文以C/C++为例,其他语言差不太多。

一、进制转换

我们需要学习下进制的相关知识,因为我们要学会2进制。世界上只有10种人,一种是懂2进制的,一种是不懂2进制的。
10进制的123为什么代表123呢,123=1*100 + 2*10 + 3*1,也就是逢10进1,10进制中每一位只会出现0~9。
我们可以写出如下代码获取3位数的前三位。

    //获取个位:直接取余
    int g = x % 10;
    //获取十位:舍去个位,再取余
    int s = x / 10 % 10;
    //获取百位:舍去后两位
    int b = x / 100;

相信你学会这个代码可以完成TZOJ1472逆置正整数,但是我们常常遇到不是3位的,我们要怎么做呢,可以从刚才的代码入手。123=1*100 + 2*10 + 3*1=1*10^2^ + 2*10^1^ + 3*10^0^,所以我们刚才的代码就是在不断地取余10得到当前这一位,除以10舍去当前位。当然我们也需要用一个数组把他存储起来,最后再进行逆置。

#include <bits/stdc++.h>
using namespace std;

//数字转数组函数,返回值为数组长度
//a数组传入的是一个指针,表示函数里可以修改a数组
int NumToArray(int n, int a[])
{
    //是0,可以直接结束
    //或者循环写成do-while形式
    if (!n)
    {
        a[0] = 0;
        return 1;
    }
    //定义变量l用来记录长度
    int l = 0;
    //n还有数可取就执行循环
    while (n)
    {
        //n%10为当前位(也就是变量n的最后一位)
        //得到当前位的数字,并加到数组a上
        a[l++] = n % 10;
        //丢弃当前位
        n /= 10;
    }
    //由于我们从最后一位到了第一位,最后需要倒过来
    for (int i = 0, t; i < l / 2; i++)
    {
        //第i位和第l-1-i位进行交换
        t = a[i], a[i] = a[l - 1 - i], a[l - 1 - i] = t;
    }
    return l;
}
int main()
{
    int n, a[100];
    while (cin >> n)
    {
        int l = NumToArray(n, a);
        for (int i = 0; i < l; i++) cout << a[i];
        cout << "
";
    }
    return 0;
}

如果是2进制呢,其实就是过程中的10需要换为2。如果是任意r进制呢,经过改造我们得到如下代码。你也可以自行实现下,去完成TZOJ1386: 进制转换

#include <bits/stdc++.h>
using namespace std;

//数字转字符串,返回值为r进制的n
string NumToString(int n, int r)
{
    //使用string类这一字符串容器
    string ans = "";
    //是0,特殊处理下
    if (!n)
        ans += '0';
    //保存n的正负,默认为正
    int sign = 0;
    if (n < 0)
        n = -n, sign = 1;
    //解决16进制:下标为0的是0,下标为10的是A
    string s = "0123456789ABCDEF";
    //n还有数可取就执行循环
    while (n)
    {
        //n%10为当前位(也就是变量n的最后一位)
        //得到当前位的数字,并按下标变为字符加到ans上
        ans += s[n % r];
        //丢弃当前位
        n /= r;
    }
    //由于我们从最后一位到了第一位,最后需要倒过来
    reverse(ans.begin(), ans.end());
    //还有可能是负数,加在前面
    if (sign == 1)
        ans = "-" + ans;
    return ans;
}
int main()
{
    int n, r;
    while (cin >> n >> r)
    {
        cout << NumToString(n, r) << "
";
    }
    return 0;
}

看不懂上面的代码也不要紧,你可以用NumToArray的代码再加上一个进制的参数。

二、原码反码及补码

有了进制转换的代码,我们就不用使劲算一个数的二进制位了,我们丢给我们的程序去算。
原码的英文名称是"Sign-Magnitude",反码的英文名称是"Ones' Complement",补码的反码的英文名称是"Two's complement"。
首先我们来看原码,就是"Sign"的"Magnitude",有符号的一个量,顾名思义它用一个位来表示一个量的符号(0正1负)。
比如int x=7;
7=4+2+1,他的二进制表示为111,当然我们也可以运行上述代码得到111
当然我们也可以int x=0b111,0b代表二进制,它是帮我们补充了前面的0的。
比如4bit(位)的二进制

非负数的原码
10进制数 二进制数
+0 0000
+1 0001
+2 0010
+3 0011
+4 0100
+5 0101
+6 0110
+7 0111
负数的原码
10进制数 二进制数
-7 1111
-6 1110
-5 1101
-4 1100
-3 1011
-2 1010
-1 1001
-0 1000
我们希望(+1)和(-1)相加是0,但计算机只能算出[0001]+[1001]=[1010] (-2) 而且还出现了一个问题就是(+0)和(-0) 为了解决正负想加等于0的问题,在原码的基础上,我们发明了反码。反码只用来处理负数,符号位置不变,其他位置取反。
负数的反码
10进制数 二进制数
-7 1000
-6 1001
-5 1010
-4 1011
-3 1100
-2 1101
-1 1110
-0 1111
也就是原码表示-7的现在表示-0了,原来表示-0的现在表示-7了,反过来了。

但是从英语这个角度看并不是这样,"Complement"是补集的意思,如果直译就是1的补集,-x(x为非负数)的反码其实就是

 -x = [11111...1] - x

比如4位数的(-1)就是(1)[0001]相对于[1111]这个集合的补集,也就是[1110]。

我们已经解决了(+1)和(-1)相加为0的问题,但是+0和-0还是困扰着我们,所以我们发明了补码,同样补码也是针对于负数的。
补码的意思就是针对反码的,在反码的基础上,进行+1形成新的码(“取反加1”)。

负数的补码
10进制数 二进制数
-8 1000
-7 1001
-6 1010
-5 1011
-4 1100
-3 1101
-2 1110
-1 1111
+1的过程中有得必有失,在补一位1的时候,要丢掉最高位。[1111]再补上一个1之后,变成了[10000],丢掉最高位就是[0000],刚好和0,完美融合掉了。这样就解决掉了+0和-0同时存在的问题。

另外两数相加等于0还满足吗?3和(-3)相加,[0011] + [1101] = [10000],丢掉最高位,就是[0000](0),仍然满足。
同样有失必有得,我们还得到了-8。[1000]需要从[10111]+1,然后丢掉最高位。
也就是反码表示-0的现在表示-1,表示-1的现在表示-2了,扩充了一个数字。

但是从英语这个角度看并不是这样,人家明明是Two,2的补集。没错,确实也可以这样理解,而且会更自然。

 -x = 2^w - x

w为位数,比如4位数的(-1)就是(1)[001]相对于(2 ^ 4)[1000]这个集合的补集,也就是[111],加上1这个表示符号的为[1111]。(-8)就是(8)[1000]相对于(2 ^ 4)[1000]的补集[0000],舍去高位,再加上符号位即[1000]。

补码的优势:

历史上有采用过反码的计算机,但是至今为止绝大部分都是补码。为什么呢,主要有两大好处。

  1. 保证了系统编码的连续性和一致性,避免了+0和-0这种很奇怪的表达。而且多表达了一个负数。
  2. 省去计算机判断符号位或者说判断+/-运算的麻烦。采用补码表示后,不管是加法还是减法都是加法运算。比如4位数的"-1+3"运算,如果用原码:10001 + 0011,结果为1100,这个结果不伦不类。我们需要检查符号位,可能还要对符号位和运算符做调整。如果用反码1110 + 0011,这样直接相加的结果不管是0001还是1001都让我们一脸懵逼。正负相加这个问题补码就可以解决,1111 + 0011 = 0010,结果得到了2。为什么呢,因为如果两个一正一负,如果正数大,那么一定会往符号为进1,符号位为0。如果两负,在相加不越界的情况下,一定会也会往符号位进1,符号位为1。这样我们就避免了对正负或加减的区别处理。

所以我们如何定义一个int为-7呢,是不是复杂了许多,当然也可以int x=-0b111;

三、字符类型的大小范围及存储

我们可以先看看ASCII表,ASCII的值范围为[0,127](表示0到127,左右均包含)。然后我们通过下面的代码跑一下。

#include <bits/stdc++.h>
using namespace std;
int main()
{
    char c;
    cout<<"char类型的所占空间大小为"<<sizeof(c)<<"Byte
";
    cout<<"char类型的最小值为"<<CHAR_MIN<<"
";
    cout<<"char类型的最大值为"<<CHAR_MAX<<"
";
}

我们可以通过sizeof查看其所占空间大小为1Byte(1字节),char的大小范围为[-128,127]即[-2^7^,2^7^-1]。1Byte=8bit,也就是1字节等于8个二进制位。你可以猜一下我下面代码的答案是多少吗,char的存储就是我们刚才所学的补码。

#include <bits/stdc++.h>
using namespace std;
int main()
{
    char c;
    c=0b11111111;
    cout<<"0b11111111的char大小为"<<(int)c<<"
";
    c=0b00000000;
    cout<<"0b00000000的char大小为"<<(int)c<<"
";
    c=0b10000000;
    cout<<"0b10000000的char大小为"<<(int)c<<"
";
    c=0b01111111;
    cout<<"0b01111111的char大小为"<<(int)c<<"
";
}

是四个比较特殊的数哦,你可以在你本地的编译器跑一下,看看是不是你猜的。

三、整型的存储

int(整型)和long long(长整型)说俺也一样,咱们把上面的代码稍加改造也来看看。

#include <bits/stdc++.h>
using namespace std;
int main()
{
    int a;
    cout<<"int类型的所占空间大小为"<<sizeof(a)<<"Byte
";
    cout<<"int类型的最小值为"<<INT_MIN<<"
";
    cout<<"int类型的最大值为"<<INT_MAX<<"
";
    long long b;
    cout<<"long long类型的所占空间大小为"<<sizeof(b)<<"Byte
";
    cout<<"long long类型的最小值为"<<LONG_MIN<<"
";
    cout<<"long long类型的最大值为"<<LONG_MAX<<"
";
}

我们运行可以得到int类型所占空间大小是4B,大小范围为[-2147483648,2147483647],long long类型所占空间大小是8B,大小范围是[-9223372036854775808,9223372036854775807]。也就分别是32位和64位的有符号整数。

但是其实C语言标准里并没有定义int的大小,他是最方便参加计算的整数,是为了实现编译器的人可以把int变成具体CPU的原生长度。所以看到大小范围为[-32768,32767]的int也不要惊慌。其实其移植性更好了,但是注意不要溢出哦。

我们这时候还要0b吗,头痛。定义一个数是-7要写int x=0b11111111111111111111111111111001;太长了,我们会选择16进制进行输入,0x为开头就好。直接int x=0xfffffff9;即可。如果0开头,代表八进制,int x=017,x是等于15的。

注意不要整型溢出

int的最大值可以粗略认为为2e9(10位数),long long可以认为9e18(19位数)。可是为什么不是2e9*2e9呢,因为其实是31位和63位,符号位只需要一位。

神奇的memset值

我们memset一个数组的无穷值常常使用0x3f,为什么要使用这个数字呢。学过补码的我们知道int的最大值为0x7fffffff。memset是一个字节一个字节的设定,这个不重复,我们稍微取小点变成0x7f,这个数字变成了2139062143与2147483647相比变小并不是很多。0x3f3f3f3f为1061109567,小了很多。但是其实是这样的,在0x3f下,两个正无穷相加是正无穷(数学⚠️),如果0x7f就溢出了,而且0x3f3f3f3f+0x3f3f3f3f=0x7e7e7e7e已经很接近0x7fffffff了,一般情况下够用,不够用用LL的INF即可,还是0x3f。

四、浮点型的存储。

浮点型就要复杂的多,我们还是老样子,看看他们的基本参数。

#include <bits/stdc++.h>
using namespace std;
int main()
{
    float a;
    cout<<"float类型的所占空间大小为"<<sizeof(a)<<"Byte
";
    cout<<"float类型的最小值为"<<FLT_MIN<<"
";
    cout<<"float类型的最大值为"<<FLT_MAX<<"
";
    double b;
    cout<<"double类型的所占空间大小为"<<sizeof(b)<<"Byte
";
    cout<<"double类型的最小值为"<<DBL_MIN<<"
";
    cout<<"double类型的最大值为"<<DBL_MAX<<"
";
}

float类型所占存储为4B,大小范围是[1.17549e-38,3.40282e+38],double类型所占存储为8B,大小范围是[2.22507e-308,1.79769e+308]。我奋力去想340282是那个数的2次方可是我想不到,而且下面的代码10个0.1相加也是不等于1。

#include <bits/stdc++.h>
using namespace std;
int main()
{
    double a=0.1,s=0;
    for(int i=0;i<10;i++)s+=a;
    printf("%.20f
",s);
}

为什么呢,因为浮点数是IEEE754标准的,和之前都不一样。看不懂也没关系,可以跳到后面的总结部分。
在这里插入图片描述
他把一个数子分为了三段,分别是符号位段(sign)、阶码位段(exponent)和尾数(fraction小数)字段,而且还规定了每个位段占多少位。
一个数V=(-1)^s^*M*2^E^
s决定这个数是负数还是正数(依旧0正1负),可以用一个单独的符号s直接编码符号s。
尾数(signficand)M是一个二进制小数,它的范围是1~2-ε或者是0~1-ε。
n位小数字段的编码尾数M

[frac=f_{n-1}...f_0 ]

阶码(exponent)E的作用是对浮点数加权,这个权重是2的E次幂(可能是负数)。k位的阶码字段的编码阶码E

[exp=e_{k-1}...e_0 ]

在单精度浮点格式(float)中,s,exp和frac字段分别为1位,8位和23位;而双精度浮点格式(double)中,s,exp和frac字段分别为1位,11位和52位。
从左到右为高位到低位
从左到右为高位到低位
不过在表达中它分别对应三种情况

  1. 规格化的值
    即最普遍的情况,当exp(即阶码段)既不为全0,也不为全1的情况。在这种情况下,阶码字段解释为以偏置(biased)形式表示有符号整数,即E=exp-Bias,单精度下exp是无符号数[1,254](有8位,且既不为全0,也不为全1)。Bias是等于2^k-1^-1的偏置值,单精度下k=23,Bias=127,因此单精度E的范围是-126~+127。
    frac被描述为小数值,且0≤frac<1,其二进制表示为0.frac。尾数定义为 M=1+frac ,即M=1.frac。那么就有1≤M<2,由于总是能够调整阶码E,使得M在范围1≤M<2,所以不需要显式的表示它,这样还能获得一个额外的精度位。也就是说,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的frac部分,等到读取的时候,再把第一位的1加上去。
    比如3.14的单精度浮点表示
    首先将3.14转成二进制:
    整数部分3的二进制是11
    小数部分0.14的二进制是:0.0010001111010111000010(0.125+0.0078125+00390625+...),所以他的22位到0位为[10001111...]
    这样,3.14的二进制码就是:11.0010001111010111000010[10001111....]×2^0^
    那么用正规化表示就是:1.10010001111010111000010[10001111....]×2^1^
    方括号表示的超出23位之后的二进制部分,由于单精度浮点数尾数只有23位,所以需要舍入,由于第24位为1,且之后 不全为0,所以需要向第23位进1完成上舍入:1.10010001111010111000011×2^1^
    而其指数是1,需要加上移码127,即128,也就是指数部分为1000 0000
    它是正数,所以符号为0
    综上所述,3.14的单精度浮点数表示为:[0][1000 0000][1001 0001 1110 1011 1000 011]
    十六进制代码为:0x4048f5c3

  2. 非规格化的值
    当exp,即阶码域为全0时,所表示的数便为非规格化的值,该情况下的阶码值E=1-Bias(和规格化一样),尾数M=frac。
    非规格化的数有两个作用:
    表示数值0。格式化数中,我们总使得M≥1,因此就无法表示0。而阶码全0时,且尾数也全0时,就可以表示0了。
    表示接近0.0的数。它所表示的值分布地接近于0.0,该属性成为逐渐溢出。

  3. 特殊值。
    特殊值有两种:
    阶码全为1,小数域全为0。它得到值为 +∞(s=0)或-∞(s=1),它在计算机中可以表示溢出的结果inf(Infinity),例如两个非常大的数相乘。
    阶码全为1,小数域不全为0。它得到值为nan(Not a Number)。它在计算机中可以表示非法的数,例如计算根号-1时的值。

总结

浮点数这个还是非常麻烦的,需要花时间去理解。当然不理解也没关系,计算机组成原理这门课还是比较硬核的。
我们用刚才得到的3.14来验证一下。

#include <bits/stdc++.h>
using namespace std;
//共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员。
union T
{
    int a;
    float b;
}data;
int main()
{
    //修改a为刚才我们所求PI值
    data.a=0b01000000010010001111010111000011;
    printf("%d
",data.a);
    printf("%.7f
",data.b);
    //修改a为刚才我们所求PI值的16进制
    data.a=0x4048f5c3;
    printf("%d
",data.a);
    printf("%.7f
",data.b);
    //手动赋值查看是否求对了
    data.b=3.14;
    printf("%d
",data.a);
    printf("%.7f
",data.b);
}

当然这个过程也可以使用指针

#include <bits/stdc++.h>
using namespace std;
int main()
{
    float f;
    //int和float都为4字节,但是需要先强制转换为整型指针
    int *i = (int*)&f;
    *i=0x4048f5c3;
    printf("%.7f
",f);
    return 0;
}

因为存在-0.0这个值,所以我们输出0的时候往往会fabs(a)<=eps,如果在eps内我们都会输出0。
他们的精度就是尾数时遇到的ε,对应的就是尾数位数,双精度浮点数的ε = 2^-52^ ≈ 2.220446049250313e-16,单精度的ε = 2^-23^ ≈ 1.1920928955078125e-7,也就是double的有效位数为15~16位,float的有效位数为6~7位。
最大值的话,我们尽力往公式里套,比如单精度最大值V=(-1)^0^*(2-2^-23^)*2^127^≈2^128^≈3.40282e+38
最小值呢V=(-1)^0^*(1+2^-23^)*2^-126^≈1.0000 0000 0000 0000 0000 001*2^-126^≈1.17549e-38
这个最小值怎么不是负的,恭喜你发现了盲点,这个最小值指的就是能表示的最接近0的最小值,改一下符号位就好了。

浮点数(double)的memset

最大值推荐 0x7f,最小值推荐 0xfe。

浮点数的3个奇怪特性

  1. 浮点数不能精确表示其范围内的所有数。
  2. 可精确表示的数不是均匀分布的,越靠近0越稠密。
  3. 不遵守普遍的算术属性,比如结合律(有可能会被卡精度)。
原文地址:https://www.cnblogs.com/BobHuang/p/12539317.html