类和对象的概述
类和对象
- 对象编程语言更符合人对自然的理解(属性 property 和 功能 function)
- 这个世界是由无数的类 (class) 和对象 (object)构成的。类是将相同的个体抽象出来的描述方式,对象是实体,其具备有独立行为的能力,一个对象是万千世界中的“一粒沙
- 具有相同属性和功能的对象属于同一类,而不同的类之间可能有联系 (继承关系)或者没有联系。
- 在C语言中,编程基于过程方法 (function) ; 在Verilog中,提供了笨拙的 ‘类对象编程’ 可能性4,即在module中定义方法,而后调用module实例中的方法。
- Verilog 的 module+method 的方式与 SV的 class定义有本质上的差别,即面向对象编程的三要素: 封装 (Encapsulation)继承 (Inheritance) 和多态 (Polymorphism) 。我们本节会阐述封装和继承,多态会在后续课程中介绍。
- 类的定义核心即是属性声明 (property declaration) 和方法定义 (method definition) ,所以类是数据和方法的自治体(self-compatible) ,即可以保存数据和处理数据。这是与 struct 结构体 在数据保存方面的重要区别,因为结构体只是单纯的数据集合,而类则可以对数据做出符合需要的处理
验证为什么需要OOP(面向对象编程) ?
那么验证世界中,为什么要有类的存在呢?
- 激励生成器 (stimulus generator ) : 生成激励内容
- 驱动器 (driver ) : 将激励以时序形式发送至DUT
- 监测器(monitor ) : 监测信号并记录数据
- 比较器(checker) : 比较数据
验证环境的不同组件其功能和所需要处理的数据内容是不相同的
不同环境的同一类型的组件其所具备的功能和数据内容是相似的
- 基于以上两点,验证世界的各个组件角色明确、功能分立,使用面向对象编程与验证世界的构建原则十分符合
第一个激励数据类
硬件 module 例化 称为 实例, 方法和变量 默认静态, 贯穿仿真开始与结束
软件 class 例化 称为 对象, 方法和变量 默认动态 , 随时开始随时结束
class 中, bit 默认变量类型 , 不能定义 reg \ wire 软件的盒子只能定义软件变量
通过接口的指针 可以传递到类里面, 可以通过接口 获取硬件数据
module 里面定义的方法只能 在 过程块 中调用
class 里面不能出现 initial, always
OOP的概念要素
- Class类: 基本模块包含成员变量和方法。在Verilog中module也可以包含变量和方法,只不过它是硬件盒子’,而class是软件盒子。
- Ojbect对象: 类的实例。Verilog 中 module也可以例化,这是 硬件’ 例化,在SV中可以使用 class来例化,这是软件 '例化。
- Handle句柄 (指针) : 用来指向对象的指针。在Verilog中,可以通过层次化的索引来找到结构中的设计实例,而在SV的对象索引时,需要通过句柄来索引对象的变量和方法
- Property属性 (变量) : 在类中声明的存储数据的变量。在Verilog中它可以是wire或者reg类型。
- Method方法: 类中可以使用 task或者 function来定义方法以便处理自身或者外部传入的数据。在Verilog中可以在module中定义 task/function,也可以使用 initial/always处理数据
创建对象
再次强调 Verilog的例化和 SV class例化的差别:
- 两者的共同点在于使用相同的模板’来创建 内存实例
- 不同点在于 Verilog的例化是 静态的,即在编译链接时完成,而 SV class的例化是 动态的,可以在任意时间点发生,这也使得类的例化方式更加灵活和节省空间。
- Verilog 中没有句柄的概念,即只能通过层次化的索引方式 A.B.sigX,而 SV class通过句柄可以将对象的指针赋予其它句柄,使得操作更加灵活。
在创建对象时,需要注意什么是 声明’,什么是创建’(即例化)
- Transaction tr; // 声明句柄
- tr = new() ; // 创建对象
Transaction tr = new()
创建对象? 它创建了什么呢?
- 开辟了新的内存空间,用来存放新的成员变量和方法
构建函数 new() 是系统预定义函数,不需要指定返回值,函数会隐式地返回例化后的对象指针。 任何类都需要 new() , new() 先给类里面的所有方法变量开辟空间, 开辟完空间后才进入 new()
句柄的传递
- 在区分了类 (抽象) 和对象 (具体)之后,初学者还需要区分对象 (存储空间) 和句柄 (空间指针)。 也就是说,在创建了对象之后,该对象的空间位置不会更改,而指向该空间的句柄可以有多个
对象的销毁
- 软件编程的灵活在于可以动态地开辟使用空间, 在资源闲置或者不再需要时, 可以回收空间,这样使得内存空间保持在一个合理的区间。
- C++语言中的类除了有构建函数,还有析构函数。析构函数的作用即在于手动释放空间,但这对编程人员的细心和经验提出了要求; Java和 Pvthon等后续面向对象语言则不再需要手动定义析构函数并且释放空间, 这意味着空间的回收利用也是自动的。
- Sv也采用了自动回收空间的处理方式,使得用户不再软件空间的开销而烦恼。那么,自动回收空间的基本原理是什么呢? 即,当一个对象,在整个程序中没有任何一个地方再需要"它时,便会被“销毁’,即回收其空间。这里需要的意思即指的是有句柄指向该对象。
句柄的使用
静态变量
- 与硬件域例如 module,interface 不同的是, 在class中声明的变量其默认类型为动态变量, 即其生命周期在仿真开始后的某时间点开始到某时间点结束。 具体来讲,其声明周期始于对象创建终于对象销毁
- 那么如果使用关键字 static 来声明 class 内的变量时,则其为静态变量。根据之前课程对变量生命 周期的描述,静态变量的生命开始于编译阶段,贯穿于整个仿真阶段。
- 如果在类中声明了静态变量,那么可以直接引用该变量 class::var ,或者通过例化对象引用 obiect.var。 类中的静态变量声明以后,无论例化多少个对象 (0…N) ,只可以共享一个同名的静态变量,因此类的静态变量在使用时需要注意共享资源的保护
静态方法
-
类似于静态变量, 在 class中定义的方法 默认类型是动态方法, 而我们也可以通过关键词 static 修改其类型为静态方法。
-
静态方法内可以声明并使用动态变量, 但是不能使用类的动态成员变量。 原因是因为在调用静态方法时, 可能并没有创建具体的对象, 也因此没有为动态成员变量开辟空间,因此在静态方法中使用类的动态成员变量是禁止的,可能会造成内存泄漏, 但是静态方法可以使用类的静态变量, 因为静态方法同静态变量一样在编译阶段就已经为其分配好了内存空间。
类的成员
概述
-
类是成员变量和成员方法的载体,之所以称之为自治体,是因为其变量和方法应符合 聚拢" 原则,即一个类的功能应该尽可能简单, 不应当承担过多的职责, 更不应该承担不符合它的职责,这 在设计模式中称之为单一职责原则 (SRP SingleResponsibility Principle)
-
类作为载体,也具备了天生的闭合属性,即将其属性和方法封装在内部,不会直接将成员变量暴露给外部,通过protected 和 local 关键词 来设置成员变量和方法的外部访问权限。所以封装属性在设计模式中称之为开放封闭原则 (OCP Open ClosedPrinciple)
-
只要添加了 local , 外部句柄没办法访问。 ck.nclock 访问不了
-
如果没有指明访问类型,那么成员的默认类型是 public(不是关键词),子类和外部均可以访问成员
-
如果指明了访问类型是 protected(关键词),那么只有该类或者子类可以访问成员, 而外部无法访问。
-
如果指明了访问类型是 local(关键词), 那么只有该类可以访问成员,子类和外部均无法访问。
-
访问类型的设定是为了更好地封装类, 尤其是要发布供他人使用的软件包 (商业软件), 但对于初学者以及应用范围较窄的验证环境, 可以使用默认的访问类型, 以便于在类的内部或者外部更方便地修改成员变量 或者调用成员方法
定义成员方法
SV中 , 如果没有创建 new() , 系统会默认创建一个 function new()
类的封装
类与结构体的异同
- 二者本身都可以定义数据成员
- 类变量在声明之后, 需要构造 (construction) 才会构建对象 (object) 实体, 而 struct 在变量声明时已经开辟内存。
- 类除了可以声明数据变量成员, 还可以声明方法 (function/task) ,而 struct则不能。
- 从根本来讲, struct仍然是一种数据结构, 而 class则包含了数据成员以及针对这些成员们操作的方法
类与模块 (module) 的异同:
-
从数据和方法定义而言,二者均可以作为封闭的容器来定义和存储
-
从例化来看, 模块必须在仿真一开始就确定是否应该被例化, 这可以通过 generate 来实现设计结构的变化;而对于类而言,它的变量在仿真的任何时段都可以被构造 (开辟内存) 创建新的对象。 这一点重要区别,按照硬件世界和软件世界区分的观点来看, 硬件部分必须在仿真一开始就确定下来,即module和其内部过程块、变量都应该是静态的 (static) ;而软件部分,即类的部分可以在仿真任何阶段声明并动态创建出新的对象,这正是软件操作更为灵活的地方。
-
从封装性 (encapsulation) 来看, 模块内的变量和方法是对外部公共 (public)开放的, 而类则可以根据需要来确定外部访问的权限是否是默认的公共类型 或者 受保护类型 (protected) 还是私有类型 (local)
-
从继承性 (inheritance) 来看,模块没有任何的继承性可言,即无法在原有module的基础上进行新module功能的扩展, 唯一可支持的方式恐怕只有简单的拷贝和在拷贝的module上做修改, 而继承性正是类的一大特点。
类的迷思
可以在哪里定义类呢?
- module, interface, program 和 package,也就是所有的盒子
可以在类中再声明类成员吗?
- 当然可以,类也是一种数据载体
What is this?
- 如果在类中使用 this,即表明 this.X 所调用的成员是当前类的成员,而非同名的局部变量或者形式参数等。
function new(string name);
this.name = name ;
endfunction
类有编译顺序吗?
- 有的! 应该先编译基本类再编译高级类。或者说,先编译将来被引用的类,再编译引用之前已编译类的类.
类的继承
概述
- 在之前介绍了如何定义类的成员, 即类的三要素之一 封装, 我们接下来需要阐述类的另外一个核心要素,即类的 继承”。
- 继承也符合我们认识实际的观点, 在自然界和科学界我们对世界的认识无外乎 归纳法 和演绎法
- 归纳论证是一种由个别到一般 的论证方法。它通过许多个别的事例或分论点,然后归纳出它们所共有的特性, 从而得出一个一般性的结论。 所以从具体对象抽象出类的属性和方法, 就符合定义类时的思维方式
- 白猫“黑猫都是猫’,谁捉到老鼠谁就是好猫。这也是一句有关类的继承的话,白猫和黑猫都继承于猫,它们有一个属性是颜色’(color) ,另外它们也有一个属性是好坏’(good)。
extends :继承关键词
set_good 外部可以访问
验证环境中的案例1
- 按照目前的课程进度,我们在环境中已经有了 generator和 driver的两个组件, 第一个组件是单纯产生激励数据,第二个组件是单纯使用激励数据来发送时序激励。
- 这种单一职责的划分,使得各个组件的任务十分明确,那么一个简单环境构建就有了
- 父类、子类的成员方法 默认没有关系, 可以用 super 来连接
- this:表示当前类型里面调用什么方法
- super: 要找到父类,继承父类的方法
验证环境中的案例2
- 上述的类 test_wr 和 test_rd 为 basic_test的子类 (派生类), basic_test 称之为 test_wr和 test_rd 的父类 (基类) test_wr 和 test_rd 继承了 basic_test的成员变量int fin,也继承了它的成员方法test()。所以,就继承来看,类的继承包括了继承父类的成员变量和成员方法
- 子类在定义 new 函数时,应该首先调用父类的 new函数即 super.new()。 new() :1. 系统通过ner()开辟空间, 2. 在new() 里面完成成员变量初始化, 3. 返回句柄。 如果父类的 new函数没有参数,子类也可以省略该调用,而系统会在编译时自动添加 super.new()。
从对象创建时初始化的顺序来看, 用户应该注意有如下的规则:
- 子类的实例对象在初始化时首先会调用父类的构造函数
- 当父类构造函数完成时, 会将子类实例对象中各个成员变量按照它们定义时显式的默认值初始化, 如果没有默认值则不被初始化。
- 在成员变量默认值赋予后 (声明的同时即赋值) ,才会最后进入用户定义的 new 函数中执行剩余的初始化代码。
成员覆盖
-
在父类和子类里,可以定义相同名称的成员变量和方法 (形式参数和返回类型也应该相同),而在引用时,也将按照句柄类型来确定作用域。
-
test_wr 类新定义的成员变量 test_wr::def 跟 basic_test::def 有冲突, 是同名的,但是在类定义里, 父类和子类拥有同名的变量和方法也是允许的。 当子类作用域中如果出现同父类相同的变量名或者方法名, 则以子类作用域为准。同时我们也提供方法 (super) 来调用父类的变量或者方法
-
在上面的输出结果中,首先 test_wr 类的对象 wr 调用构造函数 new , 而在构造函数执行序列中,也是先执行 basic_test::new,再执行 test_wr::new。在 test_wr::new中,可以通过 super.def 以及 this.def 来区分父类域的 def 或者子类域的 def。默认情况下,如果没有 super或者 this来指示作用域,则依照从近到远的原则来引用变量即:
-
- 首先看变量是否是函数内部定义的局部变量
-
- 其次看变量是否是当前类定义的成员变量
-
- 最后再看变量是否是父类或者更底层类的变量
父类句柄转换成子类句柄 唯一方法
- $cast(wr2, t), 编译时遇到 cast 会跳过, 编译的时候不会做类型检查了, 会在仿真的时候检查 t 这个句柄是否真的指向了一个子类对象, 如果是允许做转化(小三角形扩成大三角形, 因为t 指向大三角形) , 如果是 $cast(wr2, t) , 返回1,否则返回 0
句柄的使用
句柄的传递
- 句柄可以作为形式参数通过方法来完成对象指针的传递,从外部传入方法内部。 对象不能作为参数传递, 对象只能被创建放在某一个地方
- 句柄也可以在方法内部首先完成修改,而后再由外部完成使用
- tr 默认input 类型, 没有返回。 解决方法表明方向 inout / ref Transaction tr
句柄的动态修改
- 在程序执行时,可以在任何时刻为句柄创建新的对象,并将新的指针赋值给句柄
这段代码创建了 1 个对象, 因为 new() 只出现了一次, 从始至终对一个对象里的内容进行操作, 把h1, h2, h3 句柄放进队列里 , 这3 个句柄的值是一样的, 是 t , 都指向同一个对象。
包的使用
包的意义
- SV语言提供了一种在多个 module、interface 和 program之中共享 parameter、 data、 type、 task、 function、 class等的方法,即利用 package (包) 的方式来实现。 如果用装修一个大房子 (完整的验证环境)来看的话, 我们喜欢将不同模块的类定义归整到不同的 package中。
- 这么做的好处在于将一簇相关的类组织在了单一的命名空间 (namesp ace) 下, 使得分属于不同模块验证环境的类来自于不同的 package ,这样便可以通过 package 来解决类的归属问题。
包的定义
`include :纯文本替换。 把 stimulato.sv 里面每一行代码全部放入 regs_pkg 里面 , 先把各个模块的定义放入一个文件里面
- 两位 verifier 在各自的 package regs_pkg 和 arb_pkg中都定义了4个与模块验证相关的类即 stimulator、 monitor、 checker和 env。而这两个 package中 同名的类,它们的内容是不相同的, 实现的也是不同的功能
- 如果我们将这些重名的类归属到不同的 package 中编译, 有没有问题呢? 会不会发生重名的编译冲突? 同学们不需要为此担心, package是将命名空间分隔开来, 这样如果要使用不同 package中的同名类, 他们只需要注明要使用哪一个package中的。
包与库的区分
- 尽管在 regs_pkg 和 arb_pkg 中都存在着一个名字为 monitor 的类,我们可以在引用类名的时候通过域名索引 "::” 操作符的方式来显式指出所引用的 monitor 类具体来自于哪一个 package, 这样便能很好地通过不同名的package来管理同名的类。从这个简单的例子来看, package 这个容器可以对类名做一个隔离的作用。
- package更多的意义在于将软件 (类、 类型、 方法等) 封装在不同的命名空间中, 以此来与全局的命名空间进行隔离。 package 需要额外定义,容纳各种数据、方法和类
- library是编译的产物, 在没有介绍软件之前,硬件 (module、 interface、 program) 都会编译到库中,如果不指定编译库的话, 会被编译进入默认的库中。 从容纳的类型来看, 库既可以容纳硬件类型也可以容纳软件类型,例如类和方法,也包括 package。
包的命名规则
- 在创建 package的时候, 已经在指定包名称的时候隐含地指定了包的默认路径, 即包文件所在的路径。 如果有其它要被包含在包内的文件在默认路径之外, 需要在编译包的时候加上 额外指定的搜寻路径选项 "+ incdir + PATH”
- 如果遵循 package的命名习惯,不但要求定义的 package名称独无二,其内部定义的类也应该尽可能地独一无二。例如,上面的例子中 regs_pkg 和 arb_pkg中有同名的类,这些类如果携带类名的前缀, 那么后面的处理会变得更容易一些
- 如果不同 package 中定义的类名也不相同时,在顶层的引用也可以通过 " import pkg name:: "* 的形式,来表示在module mcdf_tb中引用的类如果在当前域 (mcdf内部) 中没有定义的话,会搜寻 regs_pkg和 arb pkg中定义的类,又由于它们各自包含的类名不相同, 因此也无需担心下面的搜寻会遇到同名类发生冲突的问题
这么使用包就对了
- 在包中可以定义类、 静态方法和 静态变量
- 如果将类封装在某一个包中, 那么它就不应该在其它地方编译, 这么做的好处在于之后对类的引用更加方便。
- 类和包是好朋友, 包是类的归宿, 类是包的子民
- 一个完整模块的验证环境组件类,应该由一个对应的模块包来封装
- 使用 `include 的关键词完成类在包中的封装,要注意编译的前后顺序来放置各个 'include 的类文件。
- 编译一个包的背后实际是将各个类文件 “平铺” 在包中, 按照顺序完成包和各个类的有序编译.
- 使用类的可以通过 ‘import’ 完成包中所有类或者某一个类的导入(其它的类或模块),使得新的环境可以识别出该类,否则类会躺在包这个盒子里不被外部识别。 通过 import 进入到包里面, 找到里面的方法, 软件要放在包里面。