本章主要介绍NS-3当中对象的初始化与释放。传统C++对象可以在构造函数中来初始化,然而由于NS-3对象的属性是在构造函数之后才初始化的,因此必须使用特定的初始化方法来得到正确的结果。除此之外,对象在使用完成之后也应该正确释放使用的资源。NS-3也提供了专门的方法来完成资源的释放,并且充分地考虑了聚合的因素,使得对象的释放非常完善。本章将介绍NS-3当中对象的正确初始化和释放的方法。
1. 对象的初始化
在C++当中创建对象的时候一般需要对对象进行初始化。大部分初始化的代码都在构造函数当中完成,或者独立成一个函数然后通过构造函数调用来完成。例如,如果我们用传统C++类写一个套接字(Socket)用来实现一个服务器程序,需要监听一个端口。一般程序都使用构造函数来传入端口号。然后再构造函数当中进行初始化。这里我们需要在构造函数当中初始化的时候判断一下用户程序的端口使用是否正确,因为一般的用户程序是不能使用1024以下的端口的。因此,我们使用如下的程序来使用构造函数来检查程序的端口号是否正确:
1 |
|
当我们运行程序的时候,会得到如下输出:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.27/ns-3.27/build' |
因为传入的端口好小于1024因此,程序无法正确启动服务器。如果我们将端口号改成1024,在此运行程序,就会得到如下的结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.27/ns-3.27/build' |
可以看出,现在已经可以正确启动服务器了。当然了,这只是一个例子,并没有真正去实现一个服务器程序。
2. 使用属性框架
这个程序本身并没有什么问题,但是在NS3当中写程序,很多时候不会直接使用构造函数传输参数,而是使用属性来实现,例如,我们可以将端口号作为一个属性,让用户构造对象的时候直接设置该属性(回顾属性框架一章)。
要使用属性框架,对象本身必须继承自NS3的Object类,然后在其TypeId当中添加具体的属性并绑定至具体的实例变量:
1 |
|
程序当中,我们为MyServerSocket类创建了一个属性port。主程序当中,我们使用对象工厂创建了MyServerSocket的一个实例,并且给其属性port赋值为9。随后我们启动程序,得到如下的运行结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.27/ns-3.27/build' |
这个结果是很气怪的,为什么端口号小于1024竟然可以成功启动,这跟我们程序的逻辑不符(需要注意的是,在其他的编译器上也有可能得到正确的结果,也就是程序启动失败。其主要还是看编译器对未初始化的属性的赋值情况而定:如果编译器将所有未初始化的变量初始化为0,那么结果就是正确的。在我的编译器上,所有未初始化的变量保留其内存上原来的值。)。我们在构造函数当中已经检查了属性port的值,为什么没有成功?最后输出的时候端口号的值确实也是9啊!
我们将程序进行一点点修改,在构造函数当中也输出port属性的值:
1 |
|
此时,我们再次运行程序,可以得到如下的结果:
1 | Waf: Leaving directory `/home/rainsia/Applications/ns-allinone-3.27/ns-3.27/build' |
很奇怪,在构造函数当中检查port属性的值时,是一个奇怪的数字。而在StartServer当中检查属性的值时,却是正确的。这个例子让我们了解了NS3属性初始化的时机应该是在构造函数之后,但是应该在调用StartServer之前。换句话说,我们不能在构造函数当中对NS3的属性进行判断和检查,即不能在构造函数当中去使用NS3的属性的值,因为在这个时候,属性的值实际上还没有被初始化成功(即使是属性的默认值也都还未被初始化)。那么问题来了,我们该在什么时候使用属性的值来进行对象的初始化呢?
3. NS3对象的初始化
在NS3当中,Object类提供了一个Initialize()方法。理论上这个对象可以用来作为对象初始化的方法,然而可惜的是,这个方法并不是virtual方法,因此在使用其他指针初始化对象的时候,会出现问题。然而,我们在继续在Object类当中查找,可以发现一个DoInitialize()方法,它是proected和virtual的。这个方法就是对象的初始化的方法。
接下来我们为上面的程序加上初始化方法,并将原来构造函数里面的代码放到初始化方法当中去:
1 |
|
当我们在此运行程序:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.27/ns-3.27/build' |
我们发现只要我们调用Initialize()来初始化对象,那么其结果一定是正确的。因此,我们可以得出结论,在调用Intialize()方法的时候对象的属性一定已经被ObjectFactory初始化完成了,因此我们可以放心地使用其值。在这个例子当中,不手动调用Initialize()方法是不行的,即使当端口号正确也不能正常启动服务器,原因自己体会。
4. Intialize()和DoIntialize()的特性
NS3当中对象的初始化有一些有趣的特性,下面我们通过例子来一一说明。
4.1. 自动调用Intialize()方法
从上面的例子当中我们可以看出,每个对象构造完成之后都需要调用Intialize()方法,非常麻烦。我们不禁要问,能不能让Intialize()方法自动被调用,例如在ObjectFactory构造对象的时候直接调用行不行?
理论上是可以的,只要在ObjectFactory的Create方法当中直接调用一次对象的Initialize()方法即可,简单干脆。然而,NS3并没有选择这种方案。其原因在于有时候对象的初始化还依赖于其他的因素,因此必须等到仿真开始的时候才进行初始化。NS3给出的解决方案是将初始化和对象聚合合并起来一起作。只要将任何的对象聚合到Node对象当中,那么即可以在仿真开始的时候(即Simulator::RuN()方法被调用的时候)自动初始化。例如我们将上面的代码改为:
1 | int |
程序计划在仿真时间一秒的时候调用socket对象的StartServer()方法,并且创建一个node对象,然后将socket对象聚合到node对象当中。当仿真启动的时候(仿真时间的第0秒),socket对象的Intialize()方法将被调用。然后一秒的时候,其StartServer()方法将被调用。为了更好地显示执行的过程,我在程序当中打开了MySocket日志模块的时间和节点两个前缀(忘了的话请复习日志章节)。
最后运行程序得到如下结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.27/ns-3.27/build' |
可见,确实是在0秒的时候进行对象初始化,在1秒的时候调用StartServer()方法。
可是为什么只要将对象聚合到Node当中,就可以自动被初始化?其实原因非常简单,NS3在设计Object类的时候,将相互聚合的对象的初始化方法可以相互调用。因此,只要初始化主类,那么聚合类的初始化方法便会自动被调用。可以参看Object的Intialize()方法的代码:
1 | void |
可以看出,只要初始化任何一个对象,那么跟他聚合的对象的初始化方法将被自动调用。
4.2. 初始化的时候聚合对象
在我们可以放心大胆的乱写初始化代码之前,我们还有一点顾虑,那就是万一我们在初始化代码当中聚合了新的对象怎么办?新聚合的对象能够被正确初始化吗?
从上面的Initialize()的代码当中可以看到,每次只要成功执行了一个聚合对象的DoInitialize()方法之后,都会从聚合列表的开头在重新执行一遍初始化。咋一看这段代码这么执行是没有必要的。但是Initialize()方法当中完全不知道聚合对象的DoInitialize()方法当中究竟会执行什么代码。如果其中包含了对象聚合的代码,那么聚合列表就会改变。为了以防万一,程序只能重头再将聚合列表当中的对象再次重新初始化(还记得聚合一章当中讲过所有相互聚合的对象维护的是同一张聚合列表吗?)。
我们使用下面的例子来演示在对象初始化的过程当中再次聚合对象的话也能够成功被初始化。
1 |
|
程序当中,我们创建了一个printer对象,然后向其中聚合了laserCartridge和a4Paper两个对象。在laserCartridge对象的初始化当中,我们创建了另外一个对象grayCartridge并将其聚合到了laserCartridge当中。而a4Paper对象的初始化方法当中,我们也创建了另外一个对象a3Paper,但是将其聚合到了printer当中(如何再不传入printer对象的情况下获取printer对象?这就是对象聚合的优势了)。我们在所有对象的初始化方法当中都输出正在初始化,以便观察对象是否有被正确初始化。
我们运行程序,将得到如下的结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
从运行结果可以看出,所有对象都已经被正确初始化了。原因在于,只要初始化成功一个对象,那么就从其聚合列表开始重新初始化所有对象。这保证了无论我们在初始化函数当中做什么事情,只要调用整个聚合链条上的任何一个对象的初始化方法,那么所有聚合对象都能被正确初始化。
4.3. 对象不会重复初始化
NS3在实现对象初始化特性的时候,已经考虑了重复初始化的问题。其实将Intialize()方法和DoInitialize()方法分开也是因为这个原因。从上一段代码中间也可以看出,每个对象都包含了一个m_initialized属性(包含在Object对象当中)。只有在对象的m_initialized属性为false的时候才会进行初始化,因此可以保证一个对象只会初始化一次。
1 |
|
程序当中调用了socket对象的初始化方法多次,但是,我们可以发现,最终初始化方法仅仅执行了一次。因此,我们可以放心大胆的实现初始化方法?
4.4. 父子类初始化链
在使用DoInitialize()方法的时候,在有继承关系的时候,为了能够正确地进行初始化,需要在子类当中调用父类的初始化方法,否则父类无法正确初始化,特别是在父类初始化代码当中做了很重要的事情的时候一定要注意,否则很容易引发不必要的错误。
例如在下面的例子当中,我们继承了之前的MyServerSocket类,添加新的功能:
1 |
|
代码中,我使用MySubServerSocket类继承了MyServerSocket类,提供了一个新的属性connections用于表示可以接受多少个客户端的连接,在MySubServerSocket类的初始化代码当中,我让属性等于0的时候(即不能接受客户端的情况下),变为默认可以接受5个客户端连接。在主程序当中,设置服务器端口号为8080,可以接受10个客户端连接。最后在仿真时间一秒的时候启动服务器。
运行程序,得到如下的结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
这个结果有点不可思议,为什么给服务器分配了8080这个端口号已经大于1023了,还会启动失败?仔细观察结果,可以发现MyServerSocket类当中没有输出端口号,说明其根本没有执行初始化流程。其根本原因还是在于,DoInitialize()是一个virtual方法,具有多态特性。子类重写了父类的方法之后,子类的对象执行DoInitialize()方法的时候就替代了父类的方法。因此,无法正确初始化valid变量。
解决这个问题的方法就是必须在子类中要调用父类的初始化方法,如果父类的初始化必须在子类之前进行,那么就先调用父类的方法。如果父子类的初始化之间没有明确的先后顺序,那么推荐将父类的方法放在末尾调用。例如这个例子当中,初始化端口号和初始化连接数之间并没有明确的先后顺序,那么可以将父类的方法放在最后调用:
1 |
|
这个程序唯一的变化就是在子类的DoInitialize()方法当中调用了父类的DoInitialize()方法。运行程序之后得到如下结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
可见程序已经正常执行了预期的结果。总结:子类必须在初始化方法当中调用父类的初始化方法才能确保初始化完成;如果父类的初始化方法必须先完成,那么就先调用父类的初始化方法;如果父子类的初始化方法没有明显的前后关系,那么最后再调用父类的初始化方法。
4.5. 新聚合的对象初始化
到目前为止,我们已经解析了NS3对象初始化的机制。理解了这些机制之后我们就可以放心地写出正确的对象初始化程序。然而,有的时候初始化过程已经结束之后,我们才将对象聚合到主对象当中,这些后聚合的对象还能正常初始化吗?我们通过一个例子来说明。
1 |
|
可以看到,a4Paper是在printer初始化完成之后才被聚合的。运行程序后得到如下结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
可见,一旦聚合链上的初始化过程已经完成,那么后加入的对象就不再会被初始化。如果要让后聚合的对象也能被正常初始化,可以使用聚合通知机制(参见聚合一章),让某个对象在得知有新对象加入之后,再次启动聚合链上的初始化流程。由于聚合链上的每个对象能看到的聚合链是一样的,因此任何对象来启动这个过程都可以(甚至包括新加入的对象自身)。此外,由于NS3已经防止了对象被多次重复初始化,因此,我们可以放心地在整个聚合链上启动再次初始化的过程。
我们对上面的程序进行修改,选择聚合的主对象printer来启动重新初始化的整个流程:
1 |
|
程序中,我们在聚合主类Printer的聚合通知方法当中再次启动初始化流程。运行程序后将得到如下结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
可见,此时A4Paper对象已经被正常初始化,并且所有已经初始化的对象并没有被重复初始化。
5. 对象的释放
原本对象的释放是智能指针的事情,只要指针计数降低到0之后对象自动就会被释放。然而在有些情况下,我们不得不自己释放对象。例如,再创建非NS3的对象的时候无法使用NS3的智能指针;有时候需要获取智能指针的原始指针来使用,使用完后需要自己释放;
此外,除了对象释放之外,有些资源也是需要释放或者关闭的。例如,数据库连接、网络连接、文件对象等。
做这事情最好的地方其实应该是析构函数。然而析构函数无法识别NS3的聚合机制,无法在释放一个对象的资源的同时将所有聚合链上的对象进行释放。因此,NS3设计了另外一套机制来完成资源释放。
6. Dispose()方法和DoDispose()方法
NS3用来做链式释放的方案就是Dispose()方法。和Intialize()方法类似的,NS3在Object类当中定义了Dispose()和DoDispose()两个方法。而DoDispose()是virtual方法,Dispose()方法的代码如下:
1 | void |
有了之前Intialize()方法的基础,这个代码应该是非常相似的。作用就是在任何一个对象被释放的时候调用整个对象聚合链上的所有对象的释放方法,并且最重要的,不会重复释放一个已经释放过的对象。除此之外,无论我们在DoDispose()方法当中进行任何操作(例如添加新的聚合对象),NS3都可以保证所有的对象的释放方法都会被调用一次。我们通过下面的例子来说明:
1 |
|
程序在所有类当中都加入了DoDispose()方法,默认实现仅仅输出一句话。主程序创建了一个printer对象、一个laserCartridge对象和一个a4Paper对象,并将laserCartridge和a4Paper对象都聚合到printer对象当中。此外,在laserCartridge的初始化函数当中,创建了一个grayCartridge对象,并聚合到laserCartridge当中。根据聚合的特性,实际上相当于printer对象聚合了三个对象,并且聚合链上的4个对象看到的是一个关于这四个对象的聚合列表。最后,程序调用printer对象的释放方法。我们运行程序得到如下结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
首先,当调用printer的初始化方法的时候,4个对象都能够正确初始化,这和之前的分析是一致的。其次,当调用printer的释放方法的时候,4个对象都能够被正确释放。
6.1. 自动调用Dispose()方法
和initialize方法一样的,如果是将对象聚合到Node对象上,那么在仿真结束的时候NS3会自动调用程序创建的所有Node对象的Dispose()方法,由于几乎所有对象都是聚合到Node上使用的,因此可以释放所有对象。此外,Scheduler对象本身并没有被聚合到Node对象上,因此,一般还要单独调用Scheduler对象的Desotry方法。例如下面的程序当中:
1 |
|
为MyServerSocket添加了DoDispose()方法,然后将MyServerSocket对象聚合到Node对象当中。最后开始仿真。运行程序将得到如下结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
在仿真时间第0秒的时候,socket对象被初始化,这跟之前的程序是一致的。在仿真时间第1秒的时候,启动了socket对象,这是我们自己在程序中安排的事件。随后仿真结束,同时调用了socket对象的释放方法。这个程序中加上Simulator::Destroy()将更加完善。
6.2. 父子类释放链
和DoIntialize()方法一样,DoDispose()方法也需要在父子类之间相互串联才能保证释放所有的资源。具体做法就是在子类的DoDispose()方法中调用父类的DoDispose()方法。例如:
1 |
|
程序在子类的DoDipose()方法当中关闭了客户端的连接,然后调用了父类的DoDispose()方法释放了占用的端口。运行程序得到如下结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
如果不在子类的DoDispose()方法中调用父类的方法,那么端口号就无法正确释放。因此,要想正确释放资源,在每一个子类当中都应当调用父类的DoDispose()方法。