前言
最近为了学习 Chisel,开始补一下 Scala 语法。
一开始我对 Scala 的印象其实很简单:语法上能看到一点 C/C++ 这类语言的影子,但整体写法又明显更灵活。真的上手之后才发现,它虽然也能写出很“传统”的代码,但很多地方的思路其实不太一样。比如 if 不只是流程控制语句,也可以直接当表达式返回值;函数可以像普通值一样传来传去;而且它默认鼓励你多用不可变数据。
这篇文章主要参考 Chisel Bootcamp 的 1_intro_to_scala.ipynb,把其中比较基础、后续写 Chisel 时又很常见的 Scala 语法整理一下。本文重点放在“语法入门”上,所以会尽量先把语法现象讲清楚,再配一些简单例子。
基础语法
这一部分先整理最基础、最先会碰到的语法:语句怎么结束、变量怎么定义、条件判断怎么写。
语句与分号
首先需要注意的是,与 C/C++ 不同,Scala 通常不需要在末尾添加分号。
Scala 会在换行时自动推断语句结束。例如下面这种写法是完全正常的:
val a = 1
val b = 2
val c = a + b
只有在你想把多条语句写到同一行时,才需要分号:
val a = 1; val b = 2
所以对日常开发来说,可以先把 Scala 当成一种“默认不写分号”的语言来看待。
变量与常量
Scala 里最常见的两个关键字是 var 和 val:
var:可变变量val:不可变值
示例:
var numberOfKittens = 6
val kittensPerHouse = 101
val alphabet = "abcdefghijklmnopqrstuvwxyz"
var done = false
其中 numberOfKittens 和 done 后面可以重新赋值,而 kittensPerHouse 和 alphabet 一旦创建就不能改了。
numberOfKittens += 1
done = true
// kittensPerHouse = 202
// 这行会编译失败,因为 val 不能被重新赋值
从风格上说,Scala 更鼓励优先使用 val,只有确实需要修改时再用 var。这样写的好处很直接:变量状态更少,代码更不容易绕晕自己。
另外 Scala 也支持类型推断,所以很多时候不用手写类型:
val name = "Scala"
val version = 3
编译器会自动推断 name 是 String,version 是 Int。当然你也可以手动标注:
val name: String = "Scala"
条件判断
基础的 if / else 用法和其他语言差不多:
if (numberOfKittens > kittensPerHouse) {
println("Too many kittens!")
}
也可以带 else if:
if (done) {
println("we are done")
} else if (numberOfKittens < kittensPerHouse) {
println("more kittens!")
numberOfKittens += 1
} else {
done = true
}
如果分支里只有一行语句,也可以省略大括号:
if (done)
println("we are done")
else
numberOfKittens += 1
不过比起这些表面语法,更值得记住的是:Scala 里的 if 是有返回值的。
例如:
val likelyCharactersSet =
if (alphabet.length == 26) "english"
else "not english"
这里 if 表达式的值,就是最终被赋给 likelyCharactersSet 的值。
这个特性在 Scala 里非常常见。很多时候它能帮你少写一个临时变量,也让代码更像“根据条件选择一个值”,而不是“先声明,再分支赋值”。
函数与代码块
Scala 的函数、代码块和表达式风格联系得非常紧。很多时候你会觉得自己不是在“按步骤写程序”,而是在“拼出一个结果”。这一部分把这些零散语法放到一起看,会顺很多。
函数定义:def
Scala 使用 def 定义函数:
def times2(x: Int): Int = 2 * x
这个定义可以拆开看:
def:声明函数times2:函数名(x: Int):参数列表: Int:返回值类型= 2 * x:函数体
复杂一点的函数可以写成代码块形式:
def distance(x: Int, y: Int, returnPositive: Boolean): Int = {
val xy = x * y
if (returnPositive) xy.abs else -xy.abs
}
这里最后一行 if (returnPositive) ... 的结果会自动作为整个函数的返回值。也就是说,Scala 很多代码块默认“最后一行就是返回值”。
无参函数
Scala 里没有参数的函数不一定非得写空括号:
def currentValue: Int = 42
def printValue(): Unit = println(currentValue)
一般可以先这样理解这条习惯:
- 不带副作用、只是取值的函数,常写成
def xxx: Type - 有副作用的函数,常写成
def xxx(): Type
这里的“副作用”可以简单理解成打印、修改状态、读写文件之类会对外部产生影响的行为。
重载
Scala 也支持函数重载:
def times2(x: Int): Int = 2 * x
def times2(x: String): Int = 2 * x.toInt
调用时会根据参数类型选择对应版本:
times2(5)
times2("7")
不过重载虽然能用,但如果一个函数被你重载得太花,读代码的人通常不会很开心。
递归与嵌套函数
函数内部还可以继续定义函数:
def asciiTriangle(rows: Int): Unit = {
def printRow(columns: Int): Unit = println("X" * columns)
if (rows > 0) {
printRow(rows)
asciiTriangle(rows - 1)
}
}
这里的 printRow 就是定义在 asciiTriangle 内部的局部函数,只能在当前作用域里使用。
这段代码还顺便展示了递归。asciiTriangle(rows - 1) 会不断调用自己,直到 rows 变成 0 为止。
代码块
Scala 里大括号 {} 的存在感很强,因为它不只是“包住几行代码”这么简单。
一个代码块通常有两个关键点:
- 它形成一个作用域
- 它的最后一行可以作为整个代码块的返回值
比如:
val result = {
val a = 3
val b = 4
a + b
}
最终 result 的值就是 7。
其实前面看到的很多东西,本质上都和代码块有关:
- 函数体是代码块
if/else分支是代码块- 类定义内部也是代码块
for循环体也是代码块
所以理解“最后一行会作为结果”这件事,对读 Scala 很重要。
匿名函数
后面如果接触 Chisel 或更偏函数式一点的 Scala 代码,匿名函数会非常常见。
例如:
val intList = List(1, 2, 3)
val stringList = intList.map { i =>
i.toString
}
这里的 { i => i.toString } 就是一个匿名函数。
可以把它理解成:
map会遍历intList中每个元素- 每次取出一个元素,命名为
i - 执行
i.toString - 把结果收集成一个新的列表
所以上面这段代码执行后,stringList 会变成:
List("1", "2", "3")
这类写法第一次看会觉得有点抽象,但适应之后会发现它很适合描述“对一组数据逐个做同样的处理”。
命名参数与默认参数
Scala 支持在调用函数时写出参数名,这一点我感觉挺实用,特别是参数一多、或者出现多个布尔参数的时候,可读性会好不少。
例如定义一个函数:
def myMethod(count: Int, wrap: Boolean, wrapValue: Int = 24): Unit = {
println(count, wrap, wrapValue)
}
调用时可以这样写:
myMethod(count = 10, wrap = false, wrapValue = 23)
甚至可以交换顺序:
myMethod(wrapValue = 23, wrap = false, count = 10)
因为 wrapValue 有默认值,所以也可以省略:
myMethod(wrap = false, count = 10)
这时 Scala 会自动把 wrapValue 当成 24。
这个语法的好处在于:当函数参数很多时,调用点本身就像一份说明文档。你不用倒回去数“第三个 false 到底是什么意思”。
集合与遍历
Scala 的集合库很常用,后面读 Chisel 代码时也会不断看到各种集合操作。这里先记最基础的 List 和 for。
List
Scala 自带很丰富的集合库,List 是最常见的顺序集合之一。
创建列表:
val list1 = List(1, 2, 3)
val x = 7
val y = 14
val list2 = x :: y :: y :: Nil
其中:
List(1, 2, 3)是常见写法::表示把元素接到列表头部Nil表示空列表
所以:
val list2 = 7 :: 14 :: 14 :: Nil
本质上就是构造出一个包含三个元素的列表。
列表常见操作:
val list3 = list1 ++ list2
val headOfList = list1.head
val restOfList = list1.tail
val third = list1(2)
它们分别表示:
++:拼接两个列表head:取第一个元素tail:去掉第一个元素后剩下的列表list1(2):取下标为2的元素,也就是第三个元素
如果你后面要看 Chisel 代码,那 List、Seq、Map 这些集合写法基本迟早都会见到,所以这里最好先熟悉一下。
for 循环
Scala 也有 for:
for (i <- 0 to 7) {
print(i + " ")
}
这里的 0 to 7 表示从 0 遍历到 7,右边界会包含进去。
如果不想包含右边界,可以用 until:
for (i <- 0 until 7) {
print(i + " ")
}
这个循环实际会遍历 0 到 6。
也可以指定步长:
for (i <- 0 to 10 by 2) {
print(i + " ")
}
这会输出 0 2 4 6 8 10。
除了遍历数字范围,也可以直接遍历集合:
val randomList = List(3, 5, 8, 13)
var listSum = 0
for (value <- randomList) {
listSum += value
}
这和 C 里按下标访问数组不太一样,更像“把集合中的元素依次取出来使用”。
面向对象
前面的内容更多是在说“Scala 语句怎么写”。这一部分则是从面向对象的角度整理 Scala。这样把相关概念放在一处,读起来会比零散记笔记顺很多。
值、对象与实例
Scala 是一门面向对象语言,所以类语法也很重要。
不过这里的“面向对象”不能只理解成“能写 class”。更准确地说,Scala 里很多你平时看起来像“普通值”的东西,本质上也都可以当作对象来看。
例如下面这些东西,在 Scala 的语境里都可以看作对象:
- 用
var定义出来的变量 - 用
val定义出来的值 - 字面量,比如整数、字符串、布尔值
- 函数本身
也就是说,下面这些都不只是“裸数据”:
val a = 3
val name = "scala"
val flag = true
它们都带有自己的类型,也都能调用相应的方法。例如:
val name = "scala"
println(name.toUpperCase)
println(a.toString)
如果你之前主要写 C/C++,这里会有一点不太一样。C 里整数更像“原始值”;而在 Scala 里,很多值都统一放进了对象这套思路里,所以各种操作看起来会更一致。
类与实例
在面向对象里,可以先记住一个最基本的关系:
class是模板- 根据
class创建出来的具体东西叫实例
所以平时说“创建一个对象”,很多时候其实就是“创建一个类的实例”。
来看一个简单例子:
class WrapCounter(counterBits: Int) {
val max: Long = (1 << counterBits) - 1
var counter = 0L
def inc(): Long = {
counter = counter + 1
if (counter > max) {
counter = 0
}
counter
}
}
这个类可以这样理解:
class WrapCounter(counterBits: Int):定义类,同时声明构造参数val max、var counter:成员变量def inc():类的方法
也就是说,类主要描述两类东西:
- 这个类型内部保存哪些数据
- 这个类型能执行哪些操作
这和 C++ 里的类其实很像,只是 Scala 的写法通常更紧凑一些。
创建实例时通常要用 new:
val x = new WrapCounter(2)
然后就可以调用方法:
x.inc()
这里的 x 就是 WrapCounter 这个类的一个实例。x 内部有自己的 max 和 counter,也可以调用自己的 inc() 方法。
有一个 Scala 里经常出现、第一次看可能有点怪的写法是:
x inc()
也就是点号有时可以省略。这种写法在一些 DSL 里会显得比较自然,不过对入门来说,先老老实实写 x.inc() 会更清楚。
继承与 trait
Scala 里的类也可以继承别的类:
class Counter {
var value = 0
def inc(): Unit = {
value += 1
}
}
class FastCounter extends Counter {
override def inc(): Unit = {
value += 2
}
}
这里:
Counter可以理解成父类FastCounter是子类- 子类会继承父类已有的数据和方法
- 如果想修改父类方法的行为,可以用
override
对你当前这个阶段来说,只要先记住:extends 表示“在已有类型基础上继续扩展” 就够了。后面看别人封装工具类、基础组件时会经常遇到。
Scala 里还有一个很常见的概念叫 trait。
可以先把它粗略理解成“比类更轻一点、常用来复用一部分能力的结构”:
trait Printable {
def printName(name: String): Unit = {
println(name)
}
}
class User extends Printable
这样 User 就拥有了 printName() 这个方法。
如果只从入门角度理解,我觉得可以先这样记:
class:更像完整的数据类型定义trait:更像一组可复用的能力或接口
后面看 Scala 或 Chisel 代码时,trait 的出场率很高,所以至少要先认得这个关键字。
object
Scala 里还有一个容易和“对象”这个概念搞混的语法,就是 object。
这里一定要分清楚:
- 平时说“某个类创建出来的对象”,更准确地说是实例
- Scala 关键字
object指的是单例对象
例如:
object MathTools {
def add(a: Int, b: Int): Int = a + b
}
使用时不需要 new:
MathTools.add(1, 2)
因为 object 本身就只会有一份,很适合放一些工具函数、常量,或者某些全局只需要一个实例的逻辑。
如果你有 C/C++ 背景,也可以暂时把它类比成“带命名空间效果的一组静态成员”,虽然这个类比不完全准确,但入门时够用了。
类体、主构造器与初始化
Scala 类体中不只是能写字段和方法,也能直接写普通语句:
class WrapCounter(counterBits: Int) {
val max: Long = (1 << counterBits) - 1
println(s"counter created with max value $max")
}
每次创建对象时,这行 println 都会执行。
如果从 C++ 的角度去理解,可以把 类体里那些“不是字段定义、也不是方法定义”的可执行语句,看成有点像构造对象时自动执行的初始化逻辑。
不过要注意,整个类代码块本身不能直接等同于“初始化方法”,因为它同时还承担了“定义成员变量和成员方法”的作用。更准确地说,class Xxx(...) { ... } 这整块内容可以理解成“类定义 + 主构造器主体”。
顺便这里又出现了一个常见语法:字符串插值。
val name = "Scala"
println(s"hello, $name")
println(s"1 + 2 = ${1 + 2}")
前面的 s 表示这是插值字符串,$name 会被替换成变量值,${...} 里则可以放表达式。
代码组织:package 与 import
这一部分不算最核心的语法,但实际写代码时肯定会见到。
例如:
package mytools
class Tool1 {
}
如果别的文件要使用这个类,可以导入它:
import mytools.Tool1
也可以用通配形式导入整个包里的内容:
import chisel3._
这里的 _ 很像其他语言里的 *。
如果后面开始写 Chisel,这类导入语句几乎是天天见的,所以即使现在不深究,也要先认得。
总结
对刚开始接触 Scala 的人来说,我觉得它最容易让人不适应的地方不是“太难”,而是很多地方虽然都能类比到别的语言,但细节思路经常不一样。
这篇文章先记录了后面最容易反复见到的几类基础语法:
var和valif不只是语句,也是表达式def定义函数,很多代码块默认最后一行就是返回值List、for、map这些集合相关写法- 类、实例、代码块、匿名函数、命名参数
如果只是为了后面继续学 Chisel,我感觉先掌握到这个程度已经够用了。至少再看到别人的 Scala 代码时,不会只觉得“每一行都认识,但连起来不知道在干什么”。
下一篇大概就可以开始继续记录更贴近 Chisel 使用场景的内容了。
资料来源
本文主要参考以下资料:
- Chisel Bootcamp: 1_intro_to_scala.ipynb
- freechipsproject/chisel-bootcamp: GitHub 仓库