Scala面向对象的编程特性和Chisel中的Module
动机
Scala和Chisel它们都是面向对象的编程语言,这意味着代码可以组织成对象。Scala是在Java上编译,继承Java面向对象的特征有很多,但下面我们会看到一些差异。Chisel硬件模块和Verilog模块类似,特别是它们可以实例化和连接成单个或多个示例。充分利用面向对象的特点,基本上可以轻松解决项目中的大部分问题。
面向对象编程Scala
本节总结Scala如何实现面向对象的编程范式。到目前为止,我们已经看到了很多Scala中类,但不止类,Scala还有以下特点:
- 抽象类(Abstract Classes)
- 特质(Traits)
- 对象(Objects)
- 伴生对象(Companion Objects)
- Case对象(Case Classes)
抽象类(abstract class
)
抽象类与其他编程语言中的实现相同。它们可以定义许多未实现的值,而它们的子类必须实现。任何对象都可以,只能直接从父抽象类继承。以下是一个例子:
abstract class MyAbstractClass {
def myFunction(i: Int): Int val myValue: String } class ConcreteClass extends MyAbstractClass {
def myFunction(i: Int): Int = i 1 val myValue = "Hello World!" } // 注释试验可以取消 // val abstractClass = new MyAbstractClass() // 会报错,不能从抽象实例化 val concreteClass = new ConcreteClass() // 这是可以的
特质(trait
)和多重继承
特征类似于抽象类,因为它们可以定义未实现的值,但有两个不同:
- 类别可以从多个特征继承;
- 特征不能有结构参数;
特质是Scala以下是实现多重继承的一个例子。MyClass
从HasFunction
和HasValue
这两个特质extends
而来:
trait HasFunction {
def myFunction(i: Int): Int } trait HasValue {
val myValue: String val myOtherValue = 100 } class MyClass extends HasFunction with HasValue {
override def myFunction(i: Int): Int = i 1 val myValue = "Hello World!" } // 注释试验可以取消 // val myTraitFuncton = new HasFunction() // 会报错,不能从特质实例化
// val myTraitValue = new HasValue() // 会报错,不能从特质实例化
val myClass = new MyClass() // 这样是可以的
要继承多个特质,直接用with
连起来就行:
class MyClass extends HasTrait1 with HasTrait2 with HasTrait3 ...
一般来说,用特质比用抽象类多得多,除非你确定你想强制使用抽象类的单继承限制。
对象(object
)
Scala对于这些单类(Singleton Classes)有一个语言特性,叫作对象。我们无法从一个对象创建实例(不需要new
),直接简单地引用就行。这个和Java里面的静态类是差不多的。
object MyObject {
def hi: String = "Hello World!"
def apply(msg: String) = msg
}
println(MyObject.hi)
println(MyObject("This message is important!")) // 等价于MyObject.apply(msg)
伴生对象(Companion Objects)
当一个类和一个对象名字一样且在同一文件中定义的时候,这个对象就是个伴生对象。如果使用在类名前使用new
的话,就会实例化一个类,如不使用new
的话,就会直接引用对象。
object Lion {
def roar(): Unit = println("I'M AN OBJECT!")
}
class Lion {
def roar(): Unit = println("I'M A CLASS!")
}
new Lion().roar()
Lion.roar()
伴生对象通常用于以下场合:
- 为了包含类相关的常量;
- 为了在类构造器之前或之后执行代码;
- 为了给单个类创建多个构造器;
下面的例子就实例化了几个Animal
的实例,现在我们想要每个animal都有名字,并知道实例化时的顺序。最后,如果没有给定名字的话,就会给一个默认值。
object Animal {
val defaultName = "Bigfoot"
private var numberOfAnimals = 0
def apply(name: String): Animal = {
numberOfAnimals += 1
new Animal(name, numberOfAnimals)
}
def apply(): Animal = apply(defaultName)
}
class Animal(name: String, order: Int) {
def info: String = s"Hi my name is $name, and I'm $order in line!"
}
val bunny = Animal.apply("Hopper") // 调用Animal工厂方法
println(bunny.info)
val cat = Animal("Whiskers") // 调用Animal工厂方法
println(cat.info)
val yeti = Animal() // 调用Animal工厂方法
println(yeti.info)
输出如下:
Hi my name is Hopper, and I'm 1 in line!
Hi my name is Whiskers, and I'm 2 in line!
Hi my name is Bigfoot, and I'm 3 in line!
下面分析一下代码:
-
我们的
Animal
伴生对象定义了Animal
类相关的一个常量val defaultName = "Bigfoot"
; -
同时也定义了一个私有的整数变量
private var numberOfAnimals = 0
来跟踪Animal
实例的顺序; -
还定义了两个方法,即众所周知的,来返回
Animal
类的实例。其中第一个用参数name
创建实例,然后和numberOfAnimals
一起用来调用Animal
类构造器;第二个不接受任何参数,而是使用了defaultName
来调用第一个apply
方法; -
这些工厂方法可以简单地调用:
val bunny = Animal.apply("Hopper")
-
这样就可以不使用
new
关键词了,而更神奇的是,编译器在任何时候看到实例或对象后面的圆括号时,都会默认要应用apply
方法,因此可以直接这么写:val cat = Animal("Whiskers")
-
工厂方法通常通过伴生对象提供,这样就允许用可选的方法来表达实例创建,允许给构造器参数提供额外的测试、转换,还可以消除
new
关键词。要注意的是,必须调用伴生对象的apply
方法来让numberOfAnimals
自增。
Chisel里面用到了很多的伴生对象,比如Module
就是。在写这种代码的时候:
val myModule = Module(new MyModule)
就只在调用Module
伴生对象,所以Chisel可以在实例化MyModule
前或之后运行后台代码,后面会讲到关于Module
的一些点。
Case类(Case Class
)
Case类是Scala中的一种特殊类型,提供了一下很牛掰的额外特性。它们在Scala编程中用的很多,所以这里简单概括一下它们有用的特性:
- 运行对类参数的外部访问;
- 消除了在实例化时使用
new
关键词的必要性; - 会自动创建一个
unapply
方法,用于提供对所有类参数的访问; - 不能以它们构造子类;
下面的例子中,我们声明了三个不同的类,Nail
、Screw
和Staple
:
class Nail(length: Int) // 常规class
val nail = new Nail(10) // 需要关键词new
// println(nail.length) // 报错,因为类构造器的参数不能被外部访问
class Screw(val threadSpace: Int) // 通过使用val,参数threadSpace就对外可见了
val screw = new Screw(2) // 需要关键词new
println(screw.threadSpace)
case class Staple(isClosed: Boolean) // 构造器的参数是默认对外可见的,无需val
val staple = Staple(false) // 不需要关键词new
println(staple.isClosed)
Staple
不需要new
的原因是,编译器会自动为case类创建一个伴生对象,这个伴生对象包含了一个apply
方法。
Case类对于有很多参数的生成器来说是个好的容器,因为构造器会给你单独的地方来定义导出的参数并验证输入,构造生成器需要的时候就可以直接访问这个case类的参数了:
case class SomeGeneratorParameters(
someWidth: Int,
someOtherWidth: Int = 10,
pipelineMe: Boolean = false
) {
require(someWidth >= 0)
require(someOtherWidth >= 0)
val totalWidth = someWidth + someOtherWidth
}
Chisel中的继承和Module
前面我们已经用了很多次Module
和Bundle
了,但是还是非常有必要理解一下背后的原理。我们创建的每个Chisel模块都是从基本类型Module
中extends
出来的类。我们创建的每个Chisel的IO端口都是从基本类型Bundle
中extends
出来的类(在某些特殊场合,是从Bundle
的超类型Record
创建的)。Chisel中的硬件类型比如UInt
或Bundle
都是Data
作为超类型的。我们这里将会探索怎么用面向对象编程来创建一个分层的硬件块并探索对象的复用。关于类型和Data
相关的内容会在下一篇里面讲,也就是这个系列的最后一篇。
只要你想在Chisel中创建一个硬件对象,都需要将Module
作为超类。继承可能不总是复用的好办法(组合胜过继承是个通用原则),但继承依然很强大。下面的例子中,我们会创建一个Module
,这个Module
将多个实例有层次地连接到一起。
我们现在创建一个硬件格雷码编码/解码器(格雷码_百度百科 (baidu.com)),执行编码还是解码的选择是硬件可编程的:
import scala.math.pow
// 创建一个module
class GrayCoder(bitwidth: Int) extends Module {
val io = IO(new Bundle{
val in = Input(UInt(bitwidth.W))
val out = Output(UInt(bitwidth.W))
val encode = Input(Bool()) // 为假的时候进行解码
})
when (io.encode) {
// 编码,右移后按位与就行
io.out := io.in ^ (io.in >> 1.U)
} .otherwise {
// 解码就很复杂了,具体怎么回事就不解释了
io.out := Seq.fill(log2Ceil(bitwidth))(Wire(UInt(bitwidth.W))).zipWithIndex.fold((io.in, 0)){
case ((w1: UInt, i1: Int), (w2: UInt, i2: Int)) => {
w2 := w1 ^ (w1 >> pow(2, log2Ceil(bitwidth)-i2-1).toInt.U)
(w2, i1)
}
}._1 // ._1表示两元素元组的第一个元素,._2表示第二个
}
}
跑起来,测试一下:
// 测试
val bitwidth = 4
test(new GrayCoder(bitwidth)) {
c =>
def toBinary(i: Int, digits: Int = 8) = {
String.format("%" + digits + "s", i.toBinaryString).replace(' ', '0')
}
println("Encoding:")
for (i <- 0 until pow(2, bitwidth).toInt) {
c.io.in.poke(i.U)
c.io.encode.poke(true.B)
c.clock.step(1)
println(s"In = ${
toBinary(i, bitwidth)}, Out = ${
toBinary(c.io.out.peek().litValue.toInt, bitwidth)}")
}
println("Decoding:")
for (i <- 0 until pow(2, bitwidth).toInt) {
c.io.in.poke(i.U)
c.io.encode.poke(false.B)
c.clock.step(1)
println(s"In = ${
toBinary(i, bitwidth)}, Out = ${
toBinary(c.io.out.peek().litValue.toInt, bitwidth)}")
}
}
测试通过。
格雷码常用于异构的接口。通常使用格雷码计数器而不是全特征编码/解码器,但我们将用下面的模块来做简化。下面是一个AsyncFIFO
(异步FIFO队列)的例子,利用前面的格雷码来构造。控制逻辑和测试这里就省略了,现在主要是看我们的格雷码模块是怎么多次实例化并连接在一起的。
代码如下:
class AsyncFIFO(depth: Int = 16) extends Module { val io = IO(new Bundle{ // write inputs val write_clock = Input(Clock()) val write_enable = Input(Bool()) val write_data = Input(UInt(32.W)) // read inputs/outputs val read_clock = Input(Clock()) val read_enable = Input(Bool()) val read_data = Output(UInt(32.W)) // FIFO status val full = Output(Bool()) val empty = Output(Bool()) }) // 添加额外的位来检查满/空状态 assert(isPow2(depth), "AsyncFIFO needs a power-of-two depth!") val write_counter = withClock(io.write_clock) { Counter(io.write_enable && !io.full, depth*2)._1 } val read_counter = withClock(io.read_clock) { Counter(io.read_enable && !io.empty, depth*2)._1 } // 编码,这里实例化了一个然后连在了write_counter上 val encoder = new GrayCoder(write_counter.getWidth) encoder.io.in := write_counter encoder.io.encode := true.B // 同步,
编码器的输出接到移位寄存器 val sync = withClock(io.read_clock) { ShiftRegister(encoder.io.out, 2) } // 解码,这里也实例化了一个然后连在了sync上 val decoder = new GrayCoder(read_counter.getWidth) decoder.io.in := sync decoder.io.encode := false.B // 状态逻辑转换啥的,省略了 }
可以看出,new
一个自定义的模块就可以在其他自定义模块里面使用了,而且可以多次使用。在实现一个复杂电路系统的时候,可能有很多基础的构建块是可以复用的,自定义一个Module
然后再有层级地集成到一起,可以让代码更简洁,可读性更强。