SV组件实现篇之四:激励器的封装(下)
虚方法(virtual methods)
上面谈到了类的继承是从继承成员变量和成员方法两个方面,之前的例子中可以看到test_wr和test_rd分别继承了basic_test的成员变量以及new函数。 除了我们上面介绍的类的封装和继承,关于类的多态性(polymorphism)也是必须关注的。正是由于类的多态性,使得用户在设计和实现类时,不需要担心实际对象是父类还是子类,只要通过虚方法的定义,就可以实现动态绑定(dynamic binding),或者在SV中称之为动态方法查找(dynamic method lookup)。
我们首先来看看,在上述例子中如果没有声明basic_test::test为虚方式时,下面的测试代码会输出结果会如何:

输出结果:

首先,在执行wr.test()时,由于wr类型为test_wr,则索引到的test()应该为test_wr类的方法test。同时,由于在test_wr::test中显式调用了super.test(),则会先执行basic_test::test,然后再执行test_wr::test中其余的代码。这里值得注意的是,默认情况下,子类覆盖(overridden)的方法并不会 继承父类同名的方法,而只有通过super.method()的方式显示执行,才会达到执行继承父类方法的效果,初学SV的用户容易在这里混淆方法覆盖和类继承的概念。
然而,当wr对象的句柄传递给t后,由于t本身是basic_test类,所以,在执行t.test时,t只会搜寻basic_test::test方法。如果basic_test::test已经定义过,那么就如上面输出结果所示,只会执行basic_test::test;如果basic_test::test没有定义,那么在编译时会报告错误,因为首先要确保t的类型basic_test自身已经定义test方法。
从下面的图中可以发现这种方法索引是同之前在“成员覆盖”中关于成员变量索引一致的,即索引的方法只会依照t的类型basic_test来索引:

读者可以从输出结果看到,t.test并没有执行test_wr::test,而是执行了basic_test::test。这种执行结果使得我们不得不小心句柄传递时的类型,而这种限制又跟类的多态性支持是违背的。因为父类的句柄是可以指向子类对象,但如果无法保证通过父类类型句柄调用子类方法的话,那么这种句柄的传递也就失去了多半的意义。在实际编码过程中,我们的需求要求父类句柄在调用方法时,可以在运行时确定自身的指向对象的类型,进而再调用正确地方法。
这里,我们将上面已经在编译阶段就可以确定下来调用方法所处的作用域称之为静态绑定(static binding),与之对应的动态绑定。动态绑定指的是,在调用方法时,会在运行时来确定句柄指向对象真正的类,再来动态指向该调用的方法。
为了实现动态绑定,我们将basic_test定义为虚方法:

只做了这么一个改动以后,我们继续运行之前的测试代码,可以看到运行结果变为:

可以发现,由于实现声明了t的类basic_test::test为虚方法,会在执行t.test时检查t所指向对象的真正类型为test_wr,进而调用test_wr::test,于是,输出的结果与调用wr.test一致。
这样,我们就可以通过虚方法的使用来实现类方法的调用时的动态查找,而且也使得用户无需担心使用的是父类句柄还是子类句柄,因为最终都会实现动态方法查找,执行正确的方法。

这里,我们将定义虚方法的一些建议列举出来供读者参考:
-
在为父类定义方法时,如果该方法日后可能会被覆盖或者继承,那么应该声明为虚方法。
-
虚方法如果要定义,应该尽量定义在底部父类中。这是因为如果virtual是使用在类继承关系的中间类中,那么只有从该中间类到其子类的调用链中会遵循动态查找,而最底层类到该中间类的方法调用仍然会遵循静态查找。
-
虚方法通过virtual声明,只需要声明一次即可。例如上面代码中,只需要将basic_test::test声明为virtual,而其子类则无需再次声明,当然再次声明来表明该方法的特性也是可以的。
-
虚方法的继承也需要遵循相同的参数和返回类型,否则,子类定义的方法必须归类同名不同参的另外方法。
句柄使用(handle usage)
我们在“虚方法”中可以看到发现,通过虚方法的声明使得在通过父类句柄索引子类方法时,可以通过静态绑定的形式在仿真过程中来解决。而仍然有一些成员无法通过这种方法来解决,它们包括:
-
父类没有定义,只在子类中定义了的方法,
-
父类没有声明,只在子类中声明了的变量
-
父类和子类同时声明了的变量
对于前两种情形,父类在引用成员时,会遇到编译错误,因为静态绑定会在检查句柄类型,而父类没有定义这些成员。对于后一种情况,则父类句柄只会索引到父类声明的变量,而不会索引到子类中同名的变量。
那么,在句柄使用时,我们经常会遇到下列的几种问题:
-
句柄悬空
-
句柄类型转化
-
对象拷贝
对于句柄悬空的问题,从软件层面来空有两种可能,一种是句柄原先指向的对象已经被析构(deallocation)进而销毁,另外一种是句柄在声明之后,为被指向一个有效的对象空间,即为null值。
由于SV的对象空间回收机制简单,用户无需定义析构函数,所以上述第一种可能不会存在,关于对象的垃圾回收话题,我们会在稍后的话题“对象回收”中单独解释。第二种可能则极容易在新手的代码中出现,对于悬空的句柄或者悬空的接口,都是同样需要首先被赋值,进而索引对象成员的。
我们就之前的代码中关于类stm_ini的定义:

无论是声明了句柄还是虚接口,在引用它们指向的对象成员之前,都需要为其赋值。在上面的stm_ini::stmgen中,通过wait(vif != null)来确保在调用vif中变量之前,vif已经通过外部的赋值指向了一个实例化的接口。
或者常见的其它方式例如在引用之前先检查句柄是否悬空,如下面的例子,在引用之前,先判断t是否悬空,通过这种措施可以检查句柄悬空问题,也使得运行时的调试更为方便。

关于句柄类型转化的话题也是新手容易出错的地方。由于上面已经提到,虚方法仍然无法实现一些父类句柄访问子类成员的情况,这就使得有的时候我们需要将父类句柄转化为子类句柄类型。我们都知道,子类句柄给父类句柄赋值的时候,是可以直接赋值的,因为我们将test_wr是一种basic_test是没有错的;然而,如果要将父类句柄赋值给子类句柄,则可能会出错,因为上述的句柄t指向的是basic_test的子类test_wr,而不是另外的子类test_rd。
所以,如果要将父类句柄赋值给子类句柄,我们应该做一些额外地措施来保障这一转化没有问题。我们再来看上面经过改造的例子:

对于t=wr的赋值我们不会有疑问,而hwr=t和hrd=t呢?虽然我们知道,t实际指向的是test_wr对象,那么将t赋值给一个test_wr句柄hwr,看起来也应该是允许的吧?而将t赋值给一个test_rd句柄应该是非法的,因为它是另外一个子类句柄,不可以指向test_wr对象。是这样分析的,是吗?
实际上,我们的编译器可没我们这么“聪明”,如果像我们上面那样将父类句柄再次赋值给任何的子类句柄,无论实际上是不是正确的类型,编译器都会报错。因为编译器在编译时遇到上述的赋值,只会做静态检查,即检查右侧的句柄类型是否与左侧的句柄类型兼容,而静态检查也只允许子类句柄赋值于父类句柄。所以,上述的两种赋值都是错误的。
那么,既然静态检查不允许做这样的赋值,我们只能寄希望与动态检查和转化了。这里,我们仍然要感谢$cast()系统函数。正是有了它,解决了父类句柄赋值给子类句柄这一大烦恼。
我们再来看看,经过$cast()的帮忙,上述代码的可行性:

输出结果:
# ** Error: cannot assign t to hrd
通过动态检查的方式,使得在仿真时进行检查t是否指向了一个test_wr对象或者test_rd对象,进而确定将句柄t赋值给hwr或者hrd是否正确。由于t确实指向了一个test_wr对象,所以通过$cast()返回了1,而$cast(hrd, t)则返回了0,表示这一转化是失败的。
一旦将父类句柄成功赋值给正确地子类句柄,我们就可以正常通过子类句柄来访问子类对象中的成员和方法了。如果要通过子类句柄来访问父类成员,则可以使用super来实现。
对象拷贝
对于拷贝(copy),对象的拷贝要比其它SV的变量类型都让人“牵挂”和“当心”。因为就SV普通的变量拷贝而言,只需要通过赋值“=”执行就足够了,而对象的拷贝则无法通过“=”来实现,因为这一操作时句柄的赋值,而不是对象的拷贝。
为此,我们再看这段示例代码:

输出结果:

在h=wr之后,由于是句柄的赋值,所以h.def = 300的操作,实际上是对这两个句柄指向的共同对象做的成员变量赋值。所以,最终打印的结果是wr.def与t.def的值相同。

那么,如果要赋值对象,指的是首先创建一个新的对象(开辟新的空间),再将目标对象的成员变量值拷贝给新的对象,这就使得新的对象与目标对象的成员变量数值保持一致,即完成了对象的拷贝(成员变量的拷贝)。
关于对象拷贝的方法,我们在本书中不做过多介绍。这一小节的介绍为的是帮读者理清常见的句柄拷贝与对象拷贝的区别。


输出结果:

从这个对象拷贝的例子可以看到,如果要实现一个类的全部成员变量的拷贝,合适的方法需要注意:
-
将成员拷贝copy_data()和新对象生成copy()分为两个方法,这样使得子类继承和方法复用较为容易。
-
为了保证父类和子类的成员均可以完成拷贝,将上述拷贝方法声明为虚方法,且遵循只拷贝该类作用域内的成员,父类的成员拷贝应由父类的拷贝方法完成。
-
在实现copy_data()过程中应该注意类句柄的转换,保证经过转换的句柄可以访问类成员变量。
对象回收
与C/C++对比,SV对于内存回收要容易得多。拿C++对象的析构(destructor)函数来看,当动态分配的对象不再被需要时,需要运行析构函数在释放对象的内存之前清除对象。这C++的对象回收中,这一步骤是手动执行的,如果忘记了手动释放对象,则可能造成内存泄漏。
而关于动态对象回收的问题,SV的回收机制更像Java一样,例如下面这个例子:
在SV中,只要你需要,由new创建的对象会一直保留下去。这一点并不像C++,不仅需要确保对象的保留时间与你需要这些对象的时间一样长,而且还必须在你使用完它们之后,将其销毁。同Java类似,SV也有自己的垃圾回收器,用来监视用new创建的所有对象,并辨别哪些不会再被引用的对象。
从上面的例子可以看到,当t1 = null之后,没有任何的句柄再指向obj2,所以SV就先释放obj2的空间,再到了t2 = null之后,也再没有任何句柄指向obj1,所以SV最后会释放obj1的空间。所以,对象的回收不需要SV用户额外担忧,你只需要创建对象,一旦不需要它们,它们就会自行消失。于是,通过这种“傻瓜式”的处理,消除了内存泄漏的问题。SV借鉴Java的这一优点,是对于好多初入面向对象软件编程小白们的福音啊!
至此,这一节关于SV类的核心注解就介绍到了这里。从这一节中,读者可以发现,与module的“硬封装”相比,类的“软封装”提供了诸多好处。如果将硬封装与软封装与软件编程做类比,那么硬封装则更像是面向过程(procedure oriented)的编程方式,而软封装则是面向对象(object oriented)的方式。
下一节我们将进入SV的另一大核心特性“随机化”的主要知识面介绍,希望这种抓核心知识点与应用点的化学反应能够对你深入了解SV有新的切入视角。
浙公网安备 33010602011771号