我们在学习一门新的编程语言时,永远都绕不开变量类型和控制语句,这两大块是一个程序的基本构成方式,而且我们也知道构成计算机数据的一切本质其实都是0和1,比如你运行的程序是0和1组成的,你播放的一首歌也是0和1组成的,你看的电影也是0和1组成的,所以一个数据对象肯定也是0和1组成的,一个数据对象没有类型是没有办法想象的,同样是4个字节的数据,以int,uint,float来看待都是不一样的结果,也就是如果我们设置的数据类型和读取的数据类型不一致时,绝大多数情况都会导致意外发生。
本篇文章将会从两个不同的方面来解释,第一个就是数据的本质,作为程序员不得不知道数据本质和类型背后的内容。
数据的本质
我们已经非常习惯的使用基本的数据类型,比如bool,byte,short,ushort,int,uint,long,ulong,float,double,string等等,这就是常用的大部分类型。比如我们写了int i=0;那么请你用二进制来表示这个数据,一般只要学习过计算机原理的人都比较容易就可以写出0000_0000_0000_0000_0000_0000_0000_0000,因为是32位的数据,所以必须这么写,这还是相对好理解的,如果是-1呢,学过计算机的都知道,通常在计算机中,负数采用补码的形式来存储,也就是1111_1111_1111_1111_1111_1111_1111_1111,为什么会是这个数据,一定要搞清楚,因为这个数据+1=0,而且更关键的是,这个二进制的数据只有在int下面才表示-1,如果是uint呢,表示多少?就是2^32-1,所以我们可以得出结论,相同的数据在不同的类型下,表示的数据是不一样的。
所以说,类型是什么?类型规定了生成和解析数据的规则,以便我们得到准确的数据,再比如float类型
1 float i = 1;
2 int j = BitConverter.ToInt32(BitConverter.GetBytes(i), 0);
这两行代码就是将float的i的真实数据byte[],用int去解析的话会得到什么,j=1065353216;这个数据和原来的1真是风马牛不相及啊。因为整数的存储机制我们还算比较好理解的,但是计算机只有0和1,想要存小数确实挺困难的,所以采用了一个整数+一个指数的方式来存储,比如0.1=1*10^(-1),那么0.1就可以用坐标(1,-1)来标识,想要更深入的了解浮点数的存储规则,可以查看相关的知识。
下面来看一个实际的基本应用,在实际的开发中,我们会碰到一些问题,比如数据的简单存储,我们需要将数据存储到本地的一个文件中,一般的C#教程上都只是介绍了txt文件的读写,并没有针对实际开发提出有用的见解。所以此处我们假设我们需要存储10000个数据,没有数据都是0-100的整数,刚开始学习编程的时候比较容易想到下面的方法:
1 //生成一个随机0-100的10000个数据
2 int[] data = new int[10000];
3 Random r = new Random();
4 for (int i = 0; i < 10000; i++)
5 {
6 data[i] = r.Next(0, 101);
7 }
8
9
10 System.IO.StreamWriter sw = new System.IO.StreamWriter(@"D:\123.txt", false, Encoding.Default);
11 for (int i = 0; i < 10000; i++)
12 {
13 sw.WriteLine(data[i]);
14 }
15 sw.Dispose();
读取数据的时候就反其道而行,一行行的读取,读取一行就将字符串转化成int,这种方式读取比较慢,而且数据存储浪费了硬盘空间,我们查看这个文件的大小发现,
还是占了38.1KB(这个是实际的数据,占用空间是指数据消耗掉的容量)的数据,以下是经过改良的版本:
1 System.IO.FileStream fs = new System.IO.FileStream(@"D:\123.txt", System.IO.FileMode.Create);
2 for (int i = 0; i < 10000; i++)
3 {
4 fs.WriteByte(BitConverter.GetBytes(data[i])[0]);5 }
6 fs.Dispose();
因为我们的数据都是0-100的,所以我们就存储一个字节的数据即可,这样解析的时候更加的快速,文件本身的大小也缩小到了10K,虽然直接打开txt会出现乱码(因为此处我们写的数据本来就不是字符串)。10000字节差不多就是10K的样子,那么我们还有没有可能在缩小所存储的数据呢?答案当然是可以的,此处就使用了一种简单的压缩方式,假设数据存储的顺序没有关系,那我们在存储的时候,一共也就101种数据,每种数据出现0-10000次,而已,每个数据可以表示成 数据+重复次数来表示,因为重复次数不清楚,所以需要2个字节来表示,那么每个数据占用3个字节,101*3共占用了303个字节,你看我们就把数据压缩到了0.3K大小,只是在读取数据时需要根据存储规则来反解。
所以我们在提取重复次数的时候,一般比较容易想到的是这样的代码:
1 //生成一个随机0-100的10000个数据
2 int[] data = new int[10000];
3 Random r = new Random();
4 for (int i = 0; i < 10000; i++)
5 {
6 data[i] = r.Next(0, 101);
7 }
8
9
10 short[] repeat = new short[101];//因为最多重复一万次而已
11 for (int i = 0; i < 101; i++)
12 {
13 short count = 0;
14 for (int j = 0; j < 10000; j++)
15 {
16 if (data[j] == i) count++;
17 }
18 repeat[i] = count;
19 }
这么写代码有个问题,如果不是10000个数据呢,而是1000000个呢,那么计算重复次数的代码就会非常耗时,我们可以对data进行排序再进行高效的分析,这个就是后话了。同理对于其他的float,double都是一致的效果。
关于两套类型
不知道大家在学习C#的时候,会不会碰到这样的情况,定义一个int时,还有另一种Int32,所以此处列举所有对应的类型。
1 sbyte ==== System.SByte
2 byte ==== System.Byte
3 short ==== System.Int16
4 ushort ==== System.UInt16
5 int ==== System.Int32
6 uint ==== System.UInt32
7 long ==== System.Int64
8 ulong ==== System.UInt64
9 char ==== System.Char
10 float ==== System.Single
11 double ==== System.Double
12 bool ==== System.Boolean
13 decimal ==== System.Decimal
14 string ==== System.String
15 object ==== System.Object
16 dynamic==== System.Object
使用的效果上,两个是完全等价的,编译后的结果也是一致的,给我们的感觉就是这里有两套不同的命名方式,有些地方用第一套,有些地方用第二套,相对比较乱,原理是第二套的类型是FCL中原生支持的,也叫基元类型,而第一套类型是C#编译器提供一个等价的写法而已,从我们学习C语言的基础来看,左边这套命名似乎更加的符合我们的习惯,但是碰到下面的情况又有点尴尬:
1 byte[] data = new byte[4];
2
3 //写法一
4 int i = BitConverter.ToInt32(data, 0);
5 float f = BitConverter.ToSingle(data, 0);
6
7 //写法二
8 Int32 j= BitConverter.ToInt32(data, 0);
9 Single g = BitConverter.ToSingle(data, 0);
第一种写法看上去总有点怪怪的,第二种写法阅读起来更加的舒适,实际中具体使用哪个根据自身的情况来选择,微软建议是用第一套,但是有些书籍推荐第二套。
类型背后的东西
上面的一切一切都让我们以为int i=0;i就真的只是一个4个字节的数据而已,因为我们在使用的过程中从没有发现其他东西,如果不是自己看书学习,或是从别人那里得知,就根本不会知道没有对象还包含了另外两个数据块,同步索引块和类型对象指针,这两个数据块构成了整个CLR的基础,为什么这么说呢,首先我们考虑第一个问题:
如果我写了个静态的int变量,就可以在程序的任何地方(可以在不同的线程)进行引用,获取,设置值而不用担心其他问题,比如竞争问题。不要思考也还好,一旦要去考虑这个问题的答案,背后就隐藏了一个极大的秘密,对象数据的本质。我们在实例化一个对象后,如下
1 puclic class class1
2 {
3 public static int i=0;
4 }
这行代码不仅仅只是生成了一个4个字节的变量数据,准确的说,对象i的数据部分确实是4个字节而已,但是对象本身绝对不是4个字节的问题,它还有另外两个非常重要的数据对象,叫同步索引块和类型对象块,而其中的同步索引块就控制了类型在同一瞬间只能进行一次设置,我们知道数据都是01组成,我们在执行i=0xffffff时,在另一个地方刚好获取i的值,这样就避免了万一设置到一半(i=0xff0000),我们就获取到了错误的值的可能性。
第二个有意思的问题是对象其实知道它自己的类型,这真的是一个很有意思的东西,如果上述的 i 只有4个字节的byte数据,那根本判断不出来数据类型,现在我们可以调用i.GetType()来获取i本身的类型,你可能会觉得这玩意到底有什么用,我自己定义的i我还不知道他是什么类型吗?事实上用处大了,我先说明有什么用处,在说明原因。正是因为对象自己知道自己的类型,才能执行一些类型的转换,强制转换也好,隐式转换也罢,C#所有的转换建立在这个基础之上的,再看下面的代码:
1 int i=0;
2
3 object obj=(object)i;
4
5 string m=(string)obj;
在第二行代码中,因为编译器知道object是所有类的基类,所以可以转化,但是obj对象的类型真的是object吗?答案是不一定的,因为object是所有类的基类,所以obj理论上来说可以是任何类型,此处你可以获取类型来确认,obj其实是int类型。正是因为int类型和string类型不存在继承关系,所以第三行代码报错。
上面也说了另一个数据块是类型对象指针,说明它会指向一个对象,而这个对象是关于类型的对象,该对象就是在CLR加载程序的时候创建的,我们可以通过类型对象来获取到更多有用的数据,这部分内容主要涉及到反射技术,将在以后有机会说明。
string类型特点
string类型有个非常大的特点,字符串是不易变的,所以刚开始写代码的时候容易会犯这样的错误(其实也不算错误,至少运行仍然可以运行)
1 string str = "";
2 for (int i = 0; i < 10000; i++)
3 {
4 str += "1";
5 }
虽然结果上来说,str最终是长达一万个长度的1组成的,但是这么写的效率非常的差,如果你定义了一个字符串string m="123456",它就傻傻的呆在一个内存块中,不会变化,直到被清除为止,所以上述的代码需要不停的重新分配和删除,实际的性能非常差,应该避免这种情况。关于string类型最难的就是本地化了,虽然大多数的程序员都不太关心这个问题,因为大多数的程序都只是给一个特定语言使用的,比如说中文,比如说英文,所以此处就简单的提个例子,即时两个看着不同的string,因为语言文化不一致,在比较相同的时候也是可能相同的。
数据重叠问题
虽然这个技术实际中很少碰到,但是用到的时候就特别合适,它允许数据区域进行重叠,比如和int数据和byte数据,结果就是更改了一个,另一个也会改变,代码如下:
1 [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
2 public class SomeValType
3 {
4 [System.Runtime.InteropServices.FieldOffset(0)]
5 public byte ValueByte = 0;
6 [System.Runtime.InteropServices.FieldOffset(0)]
7 public int ValueInt = 0;
8 [System.Runtime.InteropServices.FieldOffset(0)]
9 public bool ValueBool = false;
10 }
也可以自己写写代码,测试测试,还是相当有意思的。