本章主要介绍NS-3的聚合。聚合是一种对象组合和功能扩展的设计范式。是一种比类的继承更加有效的类的功能扩展方式。NS-3在其对象框架当中实现了聚合的概念以及很多工具方法,使得我们在NS-3当中使用聚合变得非常的容易。同时,NS-3当中大量的使用了对象聚合的思想来进行设计,特别是网络协议的组合方面。
1. 聚合的概念
在面向对象的程序设计当中,我们经常会使用继承来增强一个类。例如我们要实现打印机,那么我们首先会实现一个Printer抽象类。如果要增加打印机的功能,例如需要激光打印机和喷墨打印机,那么我们会通过继承Printer类来实现:LaserPrinter,InkJetPrinter。其类图关系如下所示:
这种继承关系是合理的,不管是激光打印机还是喷墨打印机都是打印机的一种。因此这是一个is-a关系。在大部分时候这种关系在面向对象程序设计当中都是使用继承来实现的。但是试想以下,这种继承情况不是永远适用的。例如,如果打印机又分为彩色的和黑白的,此外,不同的打印机能够打印的纸张型号也不同,那么再使用继承关系就变得非常不实际。因为激光打印机和喷墨打印机都需要实现不同的颜色和纸张。那么这种继承关系会变为乘法数量关系,如下图所示:
这种关系不但不合理,而且看起来反而会觉得有点傻。傻的原因在于有太多的重复,代码复用性不高。我们从另外一个角度去考虑这个问题,打印机可以认为是墨盒、纸盒等元素组成的,那么我们能不能以组合的方式来思考这个问题?例如,打印机包含墨盒和纸张。然后墨盒和纸张又分为不同的型号。用类图来表示可以表示为:
由此可见类的数量减少了,更特别的是,如果以后要增加新的墨盒和纸张类型,相对也比较简单,仅需要在墨盒和纸张类下面进行继承即可,而无需继承打印机类。看起来这种方式更加合理。这种类和类之间的关系就称为聚合(Aggregation)。
我们会发现,聚合主要是根据类型来识别的。例如Printer当中主要根据是Cartridge还是PrintPaper来识别其中主要是什么东西。
2. NS-3对象框架的聚合
NS-3当中根据类型识别的原则实现了一个通用的聚合框架。在传统的聚合当中,我们会发现一个问题,如果我们想要在打印机当中再聚合另外一个东西,例如如果打印机未来要支持扫描功能的话,那么我们在当中要新增一个Scanner属性。这么做的过程当中,我们必须要修改Printer基类,否则无法添加Scanner属性。NS-3为了使得程序扩展过程当中不需要频繁更改基类,将其对象框架设计成了一种通用的聚合范式:一个对象只要继承了Object类,其当中就可以聚合各种各样的其他对象,这些对象通过类型进行识别,每个类型的对象只能聚合一次。聚合的对象必须继承自Object类,被继承的对象至少要实现智能指针。
2.1. Object对聚合的支持
我们首先通过源代码看看NS-3当中Object类对聚合的支持:
1 | class Object : public SimpleRefCount<Object, ObjectBase, ObjectDeleter> |
上面程序中列出了和聚合相关的主要属性和方法:
- AggregateObject()方法将一个对象聚合到Object当中。
- GetObject()方法在所有聚合的对象当中查找一个指定类型T的对象,并返回。若Object当中没有聚合该类型的对象,则返回0。
- GetObject(TypeId)方法在所有聚合的对象当中查找类型为TypeId的对象,并且将其转换成T类型返回。若Object当中没有聚合该TypeId对象的对象,则返回0。
- GetAggregateIterator()方法返回一个聚合对象的迭代器,可以通过迭代其遍历所有的聚合对象。
- NotifyNewAggregate()方法,主要是通知聚合和被聚合的对象聚合已经发生了。
- m_aggregates属性主要用于存储所有的聚合对象,一般正常使用当中不会直接用到这个属性。
- ……还有一些其他的方法也会对聚合产生一些依赖,例如Initialize()方法和Dispose()方法都会对聚合的对象产生链式反应。
2.2. 聚合和获取对象的例子
从上面的代码中不难看出,在NS-3当中一个对象要聚合另外一个对象非常的简单,只需调用聚合对象的AggregateObject()方法,然后传入被聚合对象作为参数即可。需要使用到被聚合对象的时候,可以使用GetObject()方法将被聚合对象分离即可。GetObject()方法有两种重载:一个方法使用模板提供需要获取对象的类型,然后查找该类型的被聚合对象;另外一个方法需要一个TypeId作为参数,通过TypeId来查找被聚合对象。实际上,第一个方法最终也是转化为TypeId()来查找的。它们最终都调用了DoGetObject(TypeId)这个private方法。
下面通过一个简单的例子来看看如何聚合和获取聚合的对象:
1 | /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */ |
例子当中定义了CartridgeColor类及其子类GrayCartridge,表示打印机的颜色。然后,定义了Cartridge及其子类LaserCartridge表示打印机的种类。最后定义了打印机类本身。其中CartridgeColor类和Cartridge类均是抽象类,无法实例化。程序主函数当中将grayCartridge聚合到lasserCatridge当中,而又将laserCartridge聚合到printer当中。在实际调用Print()方法的时候,打印机需要确定墨盒的类型,因此使用GetObject()方法从printer当中获取打印机的墨盒类型。然后墨盒需要确定墨盒的颜色,因此又使用GetObject()方法来获取墨盒的颜色。
运行该程序,得到如下的运行结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation" |
当然在实际的应用当中,如果不小心的话,用户完全可以不聚合任何东西就进行调用。例如上面的例子当中,没有任何地方强制用户必须聚合Cartridge才能调用Print()方法。那么用户如果不聚合就调用Print()方法的话,会得到什么结果呢?
例如我们修改主函数为:
1 | int |
运行得到的结果为:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation" |
可见程序出错了,然而,并不能知道是什么错误。因此,在程序当中,为了增加健壮性我们需要在调用聚合对象时进行判断,以分辨用户是否聚合了相应的对象。根据上面的源代码可知,如果对象没有被事先聚合,那么调用GetObject()方法的时候返回的智能指针将为0,那么我们可以使用这种方法来判断是否有聚合的对象。因此我们将程序改为:
1 | …… |
此时再次运行程序,可以得到如下结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation" |
换句话说,如果我们运行程序看到这个结果,我们可以马上意识到,原来程序当中需要将cartridge聚合到printer当中,程序才能正常运行,并且程序也没有异常退出。无论从程序的用户友好性和健壮性来说都比前面的例子要好。
2.3. 多次聚合对象
在NS-3的对象框架当中,一个对象的内部可以聚合超过一个对象,只要它们类型不同,就可以加以区分而不会混淆。例如下面的例子当中,我们再在printer对象当中聚合一个纸张:
1 | /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */ |
在这个例子当中,我们对printer对象两次调用了AggregateObject()方法,聚合了两个对象。当要使用被对象的时候,只要正确使用对象的类型,即可将对象”分离“出来。
然后运行该程序,得到如下的结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation" |
在获取被聚合对象的时候,可以使用任意对象的继承层次来获取。例如上面的例子无论使用A4Paper或者PrintPaper都可以从printer当中获取相同的对象。
1 | //1. 使用PrintPaper |
如果我们看一下DoGetObject()方法的源代码,就会很明白其实现了:
1 | Ptr<Object> |
DoGetObject()实际上就是在遍历了所有被聚合的对象列表,然后判断这些对象的TypeId是不是与目标类型相同。其中在对每一个对象的判断过程当中,要遍历这个对象类型的继承树(即从本对象的类型开始一直到Object类型为止)然后一次判断继承树上有没有匹配目标类型。(此处如果找到目标类型,NS-3根据经典的”程序访问的局部性原理“做了一步优化,即根据对象被访问的次数,对被聚合对象列表进行了一次排序,访问频率高的对象排在前面,使其在下次遍历的时候,能更快被遍历到。)
2.4. 聚合相同类型的对象
NS-3当中的聚合是使用类型来判断对象的,这就意味着不能在同一个对象当中聚合两个相同类型的对象,否则无法正确”分离“它们。看下面的例子:
1 | /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */ |
在这个代码中,我们试图向printer对象聚合两个PrinterPaper的对象,它们的类型是完全一模一样的,不同点仅在于参数。当我们试图运行该程序会得到如下的结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation-type" |
从输出里面可以明确看出,NS-3不允许在程序当中聚合两个完全相同的类型。那么我们换另外一种思路来试试。看下面的例子:
1 | /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */ |
此程序当中,我们将A4Paper和A3Paper分别作为PrintPaper的子类,然后在将它们的实例聚合到printer当中去。运行该程序,得到如下的运行结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation-type" |
可以看出,两个对象只要不是完全相同的类型,即便它们的父类型相同,那么在聚合到同一个对象当中时,也不会出现错误。其次,我们可以发现,聚合了两个对象到printer当中,我们使用父类型PrintPaper来获取对象,得到的对象是A4Paper。如果仔细查看前面的DoGetObject(),不难发现原因。获取到的对象和聚合的顺序相关。因此,如果我们调换聚合的顺序:
1 | …… |
那么将得到完全不同的结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation-type" |
看了前面获取聚合对象的实现代码,这个结果应该是预料之中的。
前面的例子当中,如果已经看到要在一个对象当中聚合多个相同类型的对象是行不通的,因为NS-3默认的聚合机制是使用对象类型来区别的。虽然不太推荐,但是如果有特殊的需求,非要在一个对象当中聚合多个不同的对象,那么我们可以通过如下的方法来实现:
- 创建一个管理对象来维护多个相同类型的对象实例
- 为了将这个管理对象能够聚合到其他对象当中,这个对象必须继承Object,因此可以使用智能指针来表示
- 这个对象内部必须维护一个容器对象,例如vector或者map等等,并提供一种机制来分辨不同的对象
- 将同一个类型的多个对象聚合到这个管理对象当中
- 再将这个管理对象聚合到其他对象当中
基于以上的基本思想,我们实现了如下的程序:
1 | /* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */ |
在这个程序中,我们实现了一个管理对象ObjectMap,其内部主要是一个map对象,使用字符串来索引对象。此外,管理对象提供了AggregateObject()和GetObject()方法来聚合和获取对象。这两个方法和NS-3当中标准的方法非常相似,唯一的区别是它们需要指定一个字符串作为对象的键来索引对象。
我们运行程序之后得到如下结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregation-type-map" |
2.5. 遍历聚合对象
虽然我们使用GetObject()方法,可以方便的获取某种特定类型的对象。大部分情况下,我们只需要使用该方法即可处理大部分的用例。然而,有的时候我们需要自己去遍历整个聚合对象列表。这个时候就需要使用到GetAggregateIterator()方法和AggregateIterator内部类。
其中AggregateIterator内部类定义在Object类的内部,其形式如下:
1 | class AggregateIterator |
从AgregateIterator的定义可以看出,它完全符合经典设计模式当中迭代器模式的特性。要确定迭代器当中是否还有未访问的对象,可以调用迭代器的HasNext()方法,如果该方法返回true则说明迭代器当中还有对象,并且可以使用Next()方法来访问这个对象;返回false则说明已经没有后续对象。使用Next()方法获取的对象一定是NS3的Object对象,并且是使用智能指针指向的常量。
例如,下面的例子使用了遍历对象:
1 |
|
运行该程序,得到如下结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregate-iterator" |
可见,该程序遍历了聚合到Printer对象当中的所有对象。并且使用了GetInstanceTypeId()方法来获取的对象的类型。如果我们是为了寻找某一种特定的对象类型,那么我们可以结合TypeId一章的内容,对对象的类型进行判断。以确定哪个对象是我们需要的。
但是需要注意的是,AggreateIterator::Next()方法返回的对象是带有const修饰符的。在C++当中,在const对象上是无法调用非const方法的。例如,如下代码是无法编译通过的:
1 | …… |
其中一种解决方法是将PrintPaper、A3Paper和A4Paper的GetSize()方法全部改为const方法:
1 |
|
此时,程序已经可以正常编译。运行结果如下:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregate-iterator" |
然而,并不是所有方法都可以通过添加const参数来实现的,例如有的函数是重写父类的方法,然而这个父类不是我们可以操控的(例如,父类是NS3或者第三方提供的,我们无法直接修改),在这种情况下需要使用其他方法来绕开const的限制。如下面的例子所示:
1 | auto iter = printer->GetAggregateIterator(); |
在该例子中,GetSize()方法无需是const方法。我们先将const Object对象的指针取出,然后调用const_cast方法去除其const属性,然后在使用DynamicCast方法将Object对象转换成具体的类型,以此绕开const对象的限制。
2.6. 奇怪的聚合方式
到此位置,你们一定以为已经理解了NS-3的对象聚合了。无非就是把一些对象放到另外一个对象内吧。并且可以大胆地猜想其内部实现方式,无非就是在一个对象内部维护了一个列表,然后每次聚合对象的时候,将对象放到这个列表上。例如上面的例子当中,Printer内部必然维护了一个列表,然后每次往Printer上聚合PrintPaper和Cartridge的时候,就将要聚合的对象放到Printer的列表当中。
事实上,NS-3的聚合行为跟以上描述稍有不同。其奇怪的行为从上面的例子当中也可以发现一些端倪。当我们遍历Printer内部聚合的对象的时候,如果我们仔细观察程序的运行结果,我们会发现几个问题:
- 遍历的时候将Printer自己也输出了(这个特性后面可以用来进行对象的类型转换)。
- 程序中grayCartridge对象是聚合在laserCartridge对象当中的,可是遍历Printer的聚合对象的时候,却输出了grayCartridge。
除此之外,我们改变一下聚合遍历的程序,不遍历printer对象,而是遍历a4Paper对象:
1 | …… |
运行程序之后,得到如下结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregate-iterator" |
这个结果乍一看之下有点令人费解,a4Paper这个对象根本就没有聚合过任何其他对象啊,为何遍历它的时候,也能遍历出聚合对象。如果仔细观察,会发现对a4Paper遍历的时候输出的聚合对象和遍历printer的时候是一致的。因此,NS-3对象聚合实际上会将聚合对象和被聚合对象放到同一个列表当中去。因此NS-3当中的聚合具有以下特点:
- 一个对象刚创建的时候,其聚合列表不是空的,而是有一个对象,就是它自己。
- 当两个对象聚合后,不管对于哪个对象而言,其聚合列表都是同一个。
- 聚合对象和被聚合对象都会被放到聚合列表当中。
- 聚合是一种平铺模式,不具有层次关系。将具有聚合对象A的对象再次聚合给其他对象B,那么其A对象当中的聚合对象将全部聚合到B对象当中。
- 聚合列表的排序不是永远不变的,随着对聚合对象的访问次数变化,聚合列表顺序会按访问次数进行排序,访问越多的对象排在越前面(方便下次快速访问)。
- 暂时没有发现方法可以从聚合列表当中删除已经聚合的对象。
(具体例子待更新)
(刚创建的对象的聚合列表)
(两个对象当中聚合有同类型对象的情况)
(平铺模式的例子)
(聚合列表的排序)
2.7. 使用GetObject()进行对象类型转换
在上面遍历的例子当中,最后我们使用了DynamicCast()方法来进行类型转换。实际上,NS3在实现对象聚合的时候,很巧妙的使得GetObject()方法本身就支持对象类型的转换,例如上面的例子可以改为:
1 | auto iter = printer->GetAggregateIterator(); |
达到的效果和上面的例子是一样的。其具体原理可以查看前面提到的DoGetObject()方法的源代码。
2.8. 聚合通知
当NS-3对象之间相互聚合的时候,NS-3提供了一种机制来通知聚合和被聚合的对象,让它们清楚它们之间将产生聚合关系。这种机制在对象想自己维护聚合状态的时候将是非常有用的。NS-3的网络模型在处理节点当中的各种不同协议的时候,大量地使用了这种通知机制。因此,在深入学习NS-3的网络功能之前,必须先掌握聚合通知的运作机制。
通知机制的关键在于Object对象的NotifyNewAggregate()方法。原型为:
1 | virtual void NotifyNewAggregate (void); |
该方法是一个protected和virtual方法。默认实现为空函数。必须在子类中重写该方法才能发挥作用,并且在重写的时候需要在最后调用父类的方法,将整个调用过程串联起来。下面通过一个简单的例子来了解一下这个方法的用法:
1 |
|
程序将所有对象都添加了NotifyNewAggregate()方法。并且子类当中调用了父类的方法,直至Object对象为止。我们在主函数中,先试图将grayCartridge聚合到laserCartridge当中。运行程序后得到如下结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregate-notification" |
可见,在将grayCartridge聚合到到laserCartridge的时候,程序会通知聚合对象LaserCartridge,因为LaserCartridge对象是Cartridge的子类,因此Cartridge也会被通知。然后,因为GrayCartridge被聚合,因此GrayCartidge会被通知,而其父类CatridgeColor也会被通知。
我们修改一下程序,再继续聚合一个对象。
1 | …… |
程序在将grayCartridge聚合到laserCartirdge之后,将laserCartridge又聚合到printer当中。再将再次运行程序,得到如下结果:
1 | rainsia@rainsia-ubuntu-desktop:~/Applications/ns-allinone-3.27/ns-3.27$ ./waf --run "try-aggregate-notification" |
可见,由于将laserCartridge聚合到printer当中,因此printer本身的NotifyNewAggregate()方法会被调用,由于printer本身没有继承自其他类(除了Object之外)因此,仅仅输入Printer的调用。此外,原来laserCartridge以及和laserCartridge聚合的所有对象的NotifyNewAggregate()方法也会被调用。
在NS-3当中,NotifyNewAggregate()方法调用的原则是:
- 两个对象相互聚合,两者聚合列表里面的所有对象的NotifyNewAggregate()方法都会被分别调用。
- 每次聚合时,保证每个对象的NotifyNewAggregate()方法仅会被调用一次。
- 在NotifyNewAggregate()方法当中可以调用GetObject()方法。
- 在NotifyNewAggregate()方法当中可以安全地再次聚合其他对象。
(具体例子待更新)