C#者重建C++之路 - 运算符、内存管理辨析
一、基本操作符
C#与C++的运算符基本一样,就是算术运算符,逻辑运算符,位运算符,赋值,自增自减操作符,三元条件操作符几种。特别是C#中使用了unsafe关键字以后,同样可以使用"&,*,->"等操作符了。
{
int i = 5;
int* j = &i;
System.Console.WriteLine(*j);
}
//结果为:5
需要注意一下几点:
1.C#除法(操作数全为整数时为取整运算符)运算符"/"与取模(取余)运算符"%"是有确定结果的,不管2个操作数中有几个负数。但是在C++中,如果2个操作数中都是正数,结果是正数,如果都是负数,结果也是负数,如果只有一个是负数,则结果是不确定的,就是每个机器是不一样的。
2.C#中"%"是可以接受浮点数的,但是C++中只接受整型值,例如int,char,short,long以及对应的unsigned类型。
{
Console.WriteLine(5 % 2); // int
Console.WriteLine(-5 % 2); // int
Console.WriteLine(5.0 % 2.2); // double
Console.WriteLine(5.0m % 2.2m); // decimal
Console.WriteLine(-5.2 % 2.0); // double
}
//结果为:1,-1,0.6,0.6,-1.2
3.C#中bool型只接受true与false两个值,这个在所有的逻辑表达式中都是如此。而C++中0值代表false,非0值(包括负值)代表true。
4.C++中"->"是适用于指针类型,等同于先解引用,再调用成员的形式。例如(*p).foo等同于p->foo。
5.C#与C++中多个赋值是从右边开始计算,多次移位是从左边计算的,以C++中为例:i=j=0是先给j赋值;cout<<i<<j<<endl是先输出i。
二、sizeof操作符
C#中sizeof用于获取类型的大小(结果为int值),使用比较简单,操作数必须是类型名,而且从2.0版本以后,对基本类型使用这个操作也不用加unsafe关键字。
C++中sizeof比较复杂一点,它可以计算对象的大小,也可以计算类型的大小,返回结果为size_t,长度使用字节表示的。
语法:
sizeof(type name) - 必须加括号,用于获得类型的长度。
sizeof(exp) - 此时不加括号也是可以的,用于获得该表达式的结果的类型长度,注意这里不是真的计算表达式,不会影响表达式中的所有变量值。
cout<<sizeof(i=5)<<endl;
cout<<i<<endl;
//结果为4,0
1.C++中的根据sizeof后面操作数的不同,处理的结果也不同,也就是说使用sizeof的结果部分地依赖所涉及的类型:
- 对 char 类型或值为 char 类型的表达式做 sizeof 操作保证得1。
- 对引用类型做 sizeof 操作将返回存放此引用类型对象所需的内在空间大小。
- 对指针做 sizeof 操作将返回存放指针所需的内在大小,通常为4;注意,如果要获取该指针所指向对象的大小,则必须对指针进行引用。
- 对数组做 sizeof 操作等效于将对其元素类型做 sizeof 操作的结果乘上数组元素的个数。
2.C++中各种类型的大小是与机器有关系的,所以不要使用常量去标识相关的值,而是使用sizeof去计算。
3.C#中各种类型的大小可以参看:http://msdn.microsoft.com/zh-cn/library/eahchzkf.aspx
4.C++中sizeof的各种陷阱可以参看:http://blog.csdn.net/minkowsky/article/details/5521755
5.C++中sizeof的一些对比可以参看:http://www.cppblog.com/yearner/archive/2006/09/11/12307.html
三、内存管理操作
表达式中有一个很重要的表达式是new表达式,这个是涉及到内存管理的,这里顺便也总结对比一下。
内存管理是相当重要,又是最容易出错的环节。在C++中自己写代码管理内存的时候,常常会忘记释放内存导致内存泄露;或者常常会忘记判断内存地址的有效性,直接访问导致无法控制的情形。为了解决上述的问题,托管代码中引入了自动的内存管理机制。下面对比一下内存管理方面的细节。
1.C++中的内存管理
静态空间分配:定义变量
C++中定义变量就会分配内存空间,同时也是可以进行初始化的(当然指的是编译运行的时候,不是指编码的时候)。C++中初始化方式分为两种:复制初始化(使用"="进行赋值),直接初始化(调用构造函数)。
内置类型的初始化
初始化方式:复制初始化和直接初始化几乎没有差别。比如int a(100)与int a=100基本一样。
自动初始化:内置类型变量如果只是定义了,但是没有初始化的时候,按照下列规则自动初始化。
- 在函数体外定义的变量都初始化成 0;
- 在函数体里定义的内置类型变量不进行自动初始化。
建议每个内置类型的对象都要初始化。虽然这样做并不总是必需的,但是会更加容易和安全,除非你确定忽略初始化式不会带来风险。
类对象的初始化
初始化方式:一般直接调用类的构造函数初始化,也就是直接初始化。特别是自定义的类型,基本都属于这种情况。当然有些类是可以进行复制初始化的,比如string类型。
自动初始化:类对象的自动初始化与定义的位置无关。如果类没有显式的初始化,则会自动调用默认构造函数初始化。如果没有默认构造函数,则必须要显式调用构造函数初始化。
动态空间分配:new与delete
C++中也有称为堆的结构,用于动态分配空间,这个创建与销毁就是由new于delete表达式完成,这个连个必须配对使用,也就是new分配的空间必须要使用delete销毁。
动态创建对象
使用new动态创建对象只需指定其数据类型,而不必为该对象命名。new表达式返回指向新创建对象的指针,我们通过该指针来访问此对象。
初始化
C++ 使用直接初始化初始化动态创建的对象。如果提供了初值,new 表达式分配到所需要的内存后,用给定的初值初始化该内存空间。
自动初始化
如果不提供显式初始化,动态创建的对象与在函数内定义的变量初始化方式相同。对于类类型的对象,用该类的默认构造函数初始化;而内置类型的对象则无初始化。正如总是要初始化定义为变量的对象一样,在动态创建对象时,总是对它做初始化也是一个好办法。
值初始化 - 动态创建对象的特殊初始化方式
在类型名后面使用一对内容为空的圆括号可对动态创建的对象做值初始化。内容为空的圆括号表示虽然要做初始化,但实际上并未提供特定的初值。对于提供了默认构造函数的类类型(例如 string),没有必要对其对象进行值初始化,因为无论如何都会自动调用其默认构造函数初始化该对象。而对于内置类型或没有定义默认构造函数的类型,采用不同初始化方式则有显著的差别。
动态对象销毁
直接使用delete操作new返回的那个指针就可以了。
- 如果指针指向不是用 new 分配的内存地址,则在该指针上使用 delete 是不合法的。
- 如果指针的值为 0,则在其上做 delete 操作是合法的,但这样做没有任何意义。
- 删除指针后,该指针变成悬垂指针。悬垂指针指向曾经存放对象的内存,但该对象已经不再存在了。悬垂指针往往导致程序错误,而且很难检测出来。
- 一旦删除了指针所指向的对象,立即将指针置为 0,这样就非常清楚地表明指针不再指向任何对象。因为C++可以明确检测0指针。
上面所有的知识可以通过下面的小例子说明一下:
string *ps = new string(10, '9'); //直接初始化
string *ps1 = new string(); //值初始化为空字符串
delete pi1; //正常删除
// 错误的形式:不能释放非new创建的指针
int i;
int *pi2 = &i;
delete pi2;
//删除0指针是合法的
int *pi3 = 0;
delete pi3;
//常量指针与删除也是合法的
const string *pcs = new const string;
delete pcs;
//动态数组的删除
int *arr = new int[10];
delete [] arr;
几种常见的程序错误都与动态内存分配相关:
- 动态分配空间后,忘记使用delete回收空间。
- 删除( delete )指向动态分配内存的指针失败,因而无法将该块内存返还给自由存储区。删除动态分配内存失败称为“内存泄漏(memory leak)”。内存泄漏很难发现,一般需等应用程序运行了一段时间后,耗尽了所有内存空间时,内存泄漏才会显露出来。
- 读写已删除的对象。如果删除指针所指向的对象之后,将指针置为 0 值,则比较容易检测出这类错误。
- 对同一个内存空间使用两次 delete 表达式。当两个指针指向同一个动态创建的对象,删除时就会发生错误。如果在其中一个指针上做 delete 运算,将该对象的内存空间返还给自由存储区,然后接着 delete 第二个指针,此时则自由存储区可能会被破坏。
2.C#中的内存管理(所有托管代码的内存管理模式)
正是由于C++中的内存管理机制存在许多问题,C#中引入了自动内存管理机制,它主要是用new操作符和GC对象完成对象的创建与管理。由于C#基本上是引用类型的天下,所以内存管理主要是以管理引用类型存放的托管堆为主。
对象创建
这个过程是靠new完成的,对应的IL指令是newobj(当然了string是特殊处理的,不是通过这种方式)。
当然new不只有这一个用途,简单总结一下new的用法吧:
(1)new 运算符 - 用于创建对象和调用构造函数。
(2)new 修饰符 - 用于隐藏基类成员的继承成员。
(3)new 约束 - 用于在泛型声明中约束用作类型参数的参数的类型必须要有无参构造函数。
创建位置
值类型是放到定义的地方(如果是局部变量,放到线程堆栈中,如果是引用类型的实例变量,则放到托管堆中),引用类型是放到托管堆中(如果大于一定尺寸,则会放到大对象堆中,为了效率,垃圾回收不会压缩大对象堆,而且只会在回收托管堆中第2代对象的时候才考虑回收大对象堆,这个不是讨论的重点)。
内存管理
线程堆栈是从高地址往低地址分配的,放到堆栈中的值类型与引用类型的引用基本只要脱离了作用返回就被回收。这个不归GC管理。
托管堆是从低地址到高地址连续分配的,这不同于C++中不连续的方式。它是GC(Garbage Collector垃圾回收器)管理的主要对象,也就是引用类型实例所在的主要位置(不要忘了大对象堆哦)。
垃圾回收算法
(1)创建对象引用图
- 垃圾收集器首先假定所有在托管堆里面的对象都是不可到达的(或者说没有被引用的,不再需要的),都是垃圾。
- 从根(局部变量, 全局变量, 静态变量, 指向托管堆的CPU寄存器)上的那些变量开始, 针对每一个根上的变量,找出其引用的托管堆上的对象,将找到的对象加入这个图, 然后再沿着这个对象往下找,看看它有没有引用另外一个对象,有的话,继续将找到的对象加入图中,如果没有的话,就说明这条链已经找到尾部了。垃圾收集器就去从根上的另外一个变量开始找,直到根上的所有变量都找过了, 然后垃圾收集器才停止查找。
(2)内存释放和压缩
垃圾收集器建好这个图之后,垃圾回收器将那些没有在这个图中的对象释放。释放内存之后, 出现了内存碎片, 垃圾回收器扫描托管堆,找到连续的内存块,然后移动未回收的对象到更低的地址, 以得到整块的内存,同时所有的对象引用都将被调整为指向对象新的存储位置。由于要修改引用类型的地址,所以CLR内的程序都要挂起等待垃圾回收结束,这个是要影响程序性能的。
提高垃圾回收的效率 - 代龄
代概念的引入是为了提高垃圾收集器的整体性能。这个做法是基于下面的编程实践:
- 越新的对象,其生命周期越短。
- 越老的对象,其生命周越长。
- 新对象之间通常有强的关系并被同时访问。
- 压缩一部分堆比压缩整个堆快。
有了代的概念,垃圾回收活动就可以大部分局限于一个较小的区域来进行。这样就对垃圾回收的性能有所提高。目前的CLR支持0~2三代,每一代都有一个空间阀值条件。最新分配的对象都是0代开始,0代空间够就不执行回收;0代空间不够了(已经到阀值了),就回收0代。同理,1代空间阀值条件到了,就回收1代,同时也会回收0代,以此类推。每次垃圾回收都会提高对象的代龄,第0代得对象回收后都会变成1代,1代的回收会变成2代,2代的对象还是2代。
手动管理垃圾回收
使用System.GC类手动管理垃圾回收,比如说GC.Collect()方法可以立即执行垃圾回收。
非托管资源的释放
常见的非托管资源就是包装操作系统资源的对象,例如文件,窗口,网络连接,画笔、流对象、组件对象等,对于这类资源虽然垃圾回收器可以跟踪封装非托管资源的对象的生存期,但它不知道如何清理这些资源。这里列举几种常见的非托管资源:OdbcDataReader, OleDBDataReader, Pen, Regex, Socket, StreamWriter, ApplicationContext, Brush, Component, ComponentDesigner, Container, Context, Cursor, FileStream, Font, Icon, Image, Timer, Tooltip。
由于非托管资源是不收CLR管理的,所以需要使用额外的手段释放非托管资源:
第一种方式:Finalize方式隐式释放方式
这种方式的实现就是定义一个Finalize方法,形式上就是C++中对象的析构函数,C#中称为Finalize方法。在这个方法中释放非托管资源就可以了。
方法实现简单,但缺点明显:
(1)加入了Finalize方法的对象在垃圾回收过程中的处理方式是不同的:
垃圾回收器使用名为“终止队列”的内部结构跟踪具有 Finalize 方法的对象。每次应用程序创建具有 Finalize 方法的对象时,垃圾回收器都在终止队列中放置一个指向该对象的项。
每次垃圾回收的时候,GC会回收没有Finalize方法的对象;对于含有Finalize方法的对象,GC会把这些对象移到“准备终止对象队列”中,执行对象的Finalize方法,执行完后把对象移出队列。所以本轮垃圾回收并不会回收含有Finalize方法的对象。也就是说回收含有Finalize方法的对象至少需要2轮以上的垃圾回收。
(2)GC执行Finalize方法的时间是未知的,所以你并不知道非托管对象何时释放了。
第二种方式:Dispose模式显式释放方式
由于Finalize方式的缺点比较明显,所以还有一种显式释放非托管资源的方式:实现IDisposable接口,也就是实现Dispose方法。这种方式执行释放的时间是确定了,但是缺点是,一旦你忘了调用Dispose方法了,那就造成内存泄露了。
推荐方式:标准Dispose模式
上面两种方式各有优缺点,但是一旦结合起来,就万无一失了:既实现Dispose方法,也实现Finalize方法。这样你可以显式释放资源,提高效率;如果你忘了,Finalize方法也会给你提供一道最后的保险。实现的代码如下:
{
private bool disposed = false;
~BaseResource()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
//告诉CLR不需要执行Finalize方法
GC.SuppressFinalize(this);
}
//有些用户习惯了Close方法
public void Close()
{
Dispose();
}
//受保护的虚方法
protected virtual void Dispose(bool disposing)
{
if(!disposed)
{
if(disposing)
{
//释放托管资源
//对于执行Finalize方法来说,是不用人工干预的
//对于手动释放资源来说,是需要处理的
}
//释放非托管资源
}
disposed = true;
}
}
特别推荐:
1.C#与C++中优先级大多数情况下是一元(+,-,++,--,!)优先于二元(+,-,*,/,%等),但是为了沟通方便,推荐加括号(无疑括号优先级是最高的)。
2.C#中运算符优先级参考:http://www.cnblogs.com/weihai2003/archive/2008/10/31/1323979.html
3.C++中运算符优先级参考:http://www.cppblog.com/aqazero/archive/2006/06/08/8284.html
4.C#与C++中判定bool值的时候,不需要与字面值true/false判等,直接使用自身值就可以了。例如if(b==true)推荐写成if(b)就可以了。
5.C#与C++中简单的判断处理可以使用三元条件操作符代替,但是尽量避免嵌套使用三元操作符。
6.C#与C++中推荐使用前自增操作(先自增,再参与运算),尽量不要使用后自增操作(先参与运算,再自增),防止出现误解,例如:
cout<<(10*i++)<<endl;
int j=10;
cout<<(10*++j)<<endl;
//结果为100,110
7.C#运算符参考:http://msdn.microsoft.com/zh-cn/library/6a71f45d.aspx