31-Scala-模式匹配
1. 模式匹配#
Scala 中的模式匹配类似于 Java 中的 switch 语法,但是更加强大。
模式匹配包含一系列以 case 关键字打头的可选分支(alternative)。每一个可选分支都包括一个模式(pattern)以及一个或多个表达式,如果模式匹配了,这些表达式就会被求值。箭头符 =>
用于将「模式」和「表达式」分开。
一个 match 表达式的求值过程是按照模式给出的顺序逐一尝试的。 第一个匹配上的模式被选中,跟在这个模式后面的表达式被执行。如果匹配不成功,继续执行下一个分支进行判断。如果所有 case 都不匹配,那么会执行 case _ 分支,类似于 Java 中 default 语句。
object MatchTest {
def main(args: Array[String]): Unit = {
val op = '+'
val n1 = 20
val n2 = 10
var res = 0
op match {
case '+' => res = n1 + n2
case '-' => res = n1 - n2
case '*' => res = n1 * n2
case '/' => res = n1 / n2
case _ => println("OP ERROR!")
}
println(s"res=$res")
}
}
每个 case 中,不用显式 break 自动中断 case。=>
后面的代码块到下一个 case 之前,是作为一个整体执行,可以使用 {}
扩起来,也可以不扩。
对比 match 和 switch 需要记住三个区别:首先,Scala 的 match是一个表达式(也就是说它总是能得到一个值)。其次,Scala 的可选分支不会贯穿(fall through)到下一个 case。最后,如果没有一个模式匹配上,会抛出名为 MatchError 的异常。这意味着你需要确保所有的 case 被覆盖到,哪怕这意味着你需要添加一个什么都不做的默认 case。
2. 模式种类#
2.1 通配模式#
通配模式 _
会匹配任何对象。前面已经看到过通配模式用于默认、捕获所有的可选路径。
通配模式还可以用来忽略某个对象中你并不关心的局部。
expr match {
case BinOp(_, _, _) => println(expr + "is a binary op!")
case _ => println("It's sth else.")
}
2.2 常量模式#
常量模式仅匹配自己。
任何字面量都可以作为常量(模式)使用。例如,5、true 和 "hello" 都是常量模式。同时,任何 val 或单例对象也可以被当作常量(模式)使用。例如,Nil 这个单例对象能且仅能匹配空列表。
def describe(x: Any) = x match {
case 5 => "five"
case true => "truth"
case "hello" => "hi"
case Nil => "the empty list"
case _ => "sth else"
}
2.3 变量模式#
变量模式匹配任何对象,这一点跟通配模式相同。
不过不同于通配模式的是,Scala 将对应的变量绑定成匹配上的对象。在绑定之后, 你就可以用这个变量来对对象做进一步的处理。
示例给出了一个 针对 0 的特例和针对所有其他值的默认处理的模式匹配。默认的 case 用到了变量模式,这样就给匹配的值赋予了一个名称,不论这个值是啥。
expr match {
case 0 => "zero"
case sthElse => "!zero = " + sthElse
}
变量还是常量?
常量模式也可以有符号形式的名称。当我们把 Nil 当作一个模式的时候,实际上就是在用一个符号名称来引用常量。这里有一个相关的例子,这个模式匹配牵扯到常量 E(2.71828...) 和 Pi(3.14159...):
scala> import math.{E, Pi}
import math.{E, Pi}
scala> E match {
| case Pi => "Pi=" + Pi
| case _ => "OK"
| }
res0: String = OK
跟我们预期的一样,E 并不匹配 Pi。
Scala 编译器是如何知道 Pi 是从 scala.math 包引入的常量,而不是一个代表选择器值本身的变量呢?
Scala 采用了一个简单的词法规则来区分:一个以小写字母打头的简单名称会被当作模式变量处理,所有其他引用都是常量。
可以给 Pi 创建一个小写的别名,然后尝试如下代码:
在这里编译器甚至不允许我们添加一个默认的 case。由于 pi 是变量模式,它将会匹配所有输入,因此不可能走到后面的 case。
如果需要,仍然可以用小写的名称来作为模式常量,有两个小技巧:
- 首先,如果常量是某个对象的字段,可以在字段名前面加上限定词。例如,虽然 pi 是个变量模式,但 this.pi 或 obj.pi 是常量(模式),尽管它们以小写字母打头。
- 如果这样不行(比如说 pi 可能是个局部变量),也可以用「反引号」将这个名称包起来。这样就能再次被编译器解读为一个常量,而不是变量了。
你应该看到了,给标识符加上反引号在 Scala 中有两种用途,来帮助你从不寻常的代码场景中走出来。这里你看到的是如何将小写字母打头的标识符用作模式匹配中的常量。更早的时候,你还看到过反引号可以用来将关键字当作普通的标识符,比如 Thread.yield
() 这段代码将 yield 当作标识符而不是关键字。
【注意】如果 case _
出现在 match 中间,则表示隐藏变量名,即不使用,而不是表示默认匹配。
2.4 构造方法模式#
构造方法模式是真正体现出模式匹配威力的地方。
一个构造方法模式看上去像这样:BinOp("+", e, Number(0))
。它由一个名称(BinOp)和一组圆括号中的模式:"+"、e 和 Number(0) 组成。假定这里的名称指定的是一个样例类,这样的一个模式将首先检查被匹配的对象是否是以这个名称命名的样例类的实例,然后再检查这个对象的构造方法参数是否匹配这些额外给出的模式。
这些额外的模式意味着 Scala 的模式支持深度匹配(deep match)。这样的模式不仅检查给出的对象的顶层,还会进一步检查对 象的内容是否匹配额外的模式要求。由于额外的模式也可能是构造方法模式,用它们来检查对象内部时可以到任意的深度。
例如,如下示例给出的模式将检查顶层的对象是 BinOp,而它的第 3 个构造方法参数是一个 Number,且这个 Number 的值字段为 0。这是一个长度只有一行但深度有三层的模式。
expr match {
case BinOp("+", e, Number(0)) => println("a deep match!")
case _ =>
}
2.5 序列模式#
就跟与样例类匹配一样,也可以跟序列类型做匹配,比如 List 或 Array。使用的语法是相同的,不过现在可以在模式中给出任意数量的元素。
expr match {
case List(0, _, _) => println("Found it!")
case _ =>
}
如果你想匹配一个序列,但又不想给出多长,你可以用 _*
作为模式的最后一个元素。这个看上去有些奇怪的模式能够匹配序列中任意数量的元素,包括 0 个元素。如下示例显示了一个能匹配任意长度的以 0 开始的列表。
expr match {
case List(0, _*) => println("Found it!")
case _ =>
}
补充案例:
object ObjMatchTest {
def main(args: Array[String]): Unit = {
// ========= 1. 匹配数组 =========
for (list <- Array(List(0), List(1, 0), List(0, 0, 0), List(1, 0, 0))) {
val result = list match {
case 0 :: Nil => "0"
case x :: y :: Nil => x + " " + y
case 0 :: tail => "0 and " + tail
case _ => "OTHER"
}
// 0
// 1 0
// 0 and List(0, 0)
// OTHER
println(result)
}
// ========= 2. 匹配元组 =========
for (pair <- Array((0, 1), (0, 1, 2), (1, 0), (1, 1), (1, 0, 2))) {
val result = pair match {
case (0, _) => "0,_"
case (0, x, y) => "0 " + x + " " + y
case (y, 0) => y
case _ => "OTHER"
}
// 0,_
// 0 1 2
// 1
// OTHER
// OTHER
println(result)
}
// ========= 3. 匹配对象(一) =========
// 对象匹配,怎样才算是匹配呢?
// - case 中对象的 unapply 方法(对象提取器)返回 Some 集合则为匹配成功
// - 返回 None 集合则为匹配失败
object Square {
def apply(z: Double): Double = z * z
def unapply(z: Double): Option[Double] = Some(math.sqrt(z))
}
val number: Double = Square(13.0)
var ret: Double = 0
number match {
case Square(n) => ret = n
case _ => println("No Match")
}
println(number + " ~ " + ret) // 169.0 ~ 13.0
// ========= 4. 匹配对象(二) =========
// 当 case 后面的对象提取器方法的参数为多个,则会默认调用 unapplySeq 方法
// 如果 unapplySeq 返回是 Some,获取其中的值,判断得到的元素个数是否是 3 个,如果是,则把 3 个元素分别取出并赋值给对应变量
object Names {
def unapplySeq(str: String): Option[Seq[String]] = if (str.contains(",")) Some(str.split(",")) else None
}
val namesStrList = List("Nayeon,Jeongyeon,Jihyo", "MoMo,Sana,Mina", "Dahyun,Chaeyoung,Tzuyu", "twice")
for (namesString <- namesStrList) {
namesString match {
case Names(first, second, third) => println(s"$first-$second-$third")
case _ => println("Nothing Matched")
}
// Nayeon-Jeongyeon-Jihyo
// MoMo-Sana-Mina
// Dahyun-Chaeyoung-Tzuyu
// Nothing Matched
}
}
}
2.6 元组模式#
我们还可以匹配元组。形如 (a, b, c) 这样的模式能匹配任意的三元组。
def tupleDemo(expr: Any) = expr match {
case (a, b, c) => println(s"Match: $a - $b - $c")
case _ =>
}
tupleDemo((1, 2, 3))
2.7 带类型的模式#
可以用带类型的模式(typed pattern)来替代类型测试和类型转换。
generalSize 方法返回不同类型的对象的大小或长度。其入参的类型是Any,因此可以是任何值。如果入参是 String,那么方法将返回这个字符串的长度。模式 s: String
是一个带类型的模式,它将匹配每个(非 null 的)String 实例。其中的模式变量 s 将指向这个字符串。
需要注意的是,尽管 s 和 x 指向同一个值,x 的类型是 Any,而 s 的类型是 String。因此可以在与模式相对应的可选分支中使用 s.length,但不能写成 x.length,因为类型 Any 并没有一个叫作 length 的成员。
另一个跟用带类型的模式匹配等效但是更冗长的方式是做类型测试然后(强制)类型转换。对于类型测试和转换,Scala 跟 Java 的语法不太一样。比方说要测试某个表达式 expr 的类型是否为 String,我们需要通过类型测试(isInstanceOf)和类型转换(asInstanceOf)。isInstanceOf 和 asInstanceOf 两个操作符会被当作 Any 类的预定义方法处理,这两个方法接收一个用方括号 []
括起来的类型参数。
你现在应该已经注意到了,在 Scala 中编写类型测试和类型检查会比较啰唆。我们是有意为之,因为这并不是一个值得鼓励的做法。通常,使用带类型的模式会更好,尤其是当你需要同时做类型测试和类型转换的时候,因为这两个操作所做的事情会被并在单个模式匹配中完成。
示例中的 match 表达式的第二个 case 包含了带类型的模式 m: Map[_, _]
。这个模式匹配的是任何 Map 值,不管它的键和值的类型是什么,然后让 m 指向这个值。因此,m.size 的类型是完备的,返回的是这个映射(map)的大小。类型模式(type pattern)中的下划线就像是其他模式中的通配符。除了用下划线,也可以用(小写的)类型变量。
除了笼统的映射,我们还能测试特定元素类型的映射吗?这对于测试某个值是否是 Int 到 Int 的映射这类场景会很方便。
- No!Scala 采用了擦除式的泛型,就跟 Java 一样。这意味着在运行时并不会保留类型参数的信息。系统能做的只是判断某个值是某种不确定类型参数的 Map。
- 对于这个擦除规则唯一的例外是数组,因为 Java 和 Scala 都对它们做了特殊处理。数组的元素类型是跟数组一起保存的,因此我们可以对它进行模式匹配。
2.8 变量绑定#
除了独自存在的变量模式,我们还可以对任何其他模式添加变量。只需要写下变量名、一个 @ 符和模式本身,就得到一个变量绑定模式。意味着这个模式将跟平常一样执行模式匹配,如果匹配成功,就将匹配的对象赋值给这个变量,就像简单的变量模式一样。
expr match {
case UnOp("abs", e @ UnOp("abs", _)) => e
case _ =>
}
示例包括了一个以 e 为变量,UnOp("abs", _)
为模式的变量绑定模式。如果整个匹配成功了,那么匹配了 UnOp("abs", _)
的部分就被赋值给变量 e。这个 case 的结果就是 e,这是因为 e 跟 expr 的值相同,但是少了一次求绝对值的操作。
2.9 Option 类型#
Scala 由一个名为 Option 的标准类型来表示可选值。这样的值可以有两种形式:
- Some(x),其中 x 是那个实际的值
- None 对象,代表没有值
Scala 集合类的某些标准操作会返回可选值。比如,Scala 的 Map 有一个 get 方法,当传入的键有对应的值时,返回 Some(value);而当传入的键在 Map 中没有定义时,返回 None。
将可选值解开最常见的方式是通过模式匹配。
3. 模式守卫#
如果想要表达匹配某个范围的数据,就需要在模式匹配中增加条件守卫。
object MatchTest {
def main(args: Array[String]): Unit = {
for (ch <- "+-3!") {
var sign = 0
var digit = 0
ch match {
case '+' => sign = 1
case '-' => sign = -1
// 模式匹配守卫
case _ if ch.toString.equals("3") => digit = 3
case _ => sign = 2
}
// + 1 0
// - -1 0
// 3 0 3
// ! 2 0
println(ch + " " + sign + " " + digit)
}
println("====================")
for (ch <- "+-3!") {
var sign = 0
var digit = 0
ch match {
case '+' => sign = 1
case '-' => sign = -1
case _ => sign = 2
// 这个永远到不了
case _ => digit = 3
}
// + 1 0
// - -1 0
// 3 2 0
// ! 2 0
println(ch + " " + sign + " " + digit)
}
println("====================")
for (ch <- "+-3!") {
var sign = 0
var digit = 0
ch match {
// 只会走第一个
case _ => digit = 3
case '+' => sign = 1
case '-' => sign = -1
}
// + 0 3
// - 0 3
// 3 0 3
// ! 0 3
println(ch + " " + sign + " " + digit)
}
}
}
4. 到处都是模式#
Scala 中很多地方都允许使用模式,并不仅仅是 match 表达式。我们来看看其他能用模式的地方。
4.1 变量定义中的模式#
每当我们定义一个 val 或 var,都可以用模式而不是简单的标识符。例如,可以将一个元组解开并将其中的每个元素分别赋值给不同的变量。
scala> val t = (123, "abc")
t: (Int, String) = (123,abc)
scala> val (num, str) = t
num: Int = 123
str: String = abc
4.2 作为偏函数的 case 序列#
用花括号包起来的一系列 case(即可选分支)可以用在任何允许出现「函数字面量」的地方。
从本质上讲,case 序列就是一个函数字面量,只是更加通用。不像普通函数那样只有一个入口和参数列表, case 序列可以有多个入口,每个入口都有自己的参数列表。每个 case 对应该函数的一个入口,而该入口的参数列表用模式来指定。每个入口的逻辑主体是 case 右边的部分。
scala> val withDefault: Option[Int] => Int = {
| case Some(x) => x
| case None => 0
| }
withDefault: Option[Int] => Int = $Lambda$1339/1623624827@1315d910
该函数的函数体有两个 case。第一个 case 匹配 Some,返回 Some 中的值。第二个 case 匹配 None,返回默认值 0。以下是这个函数用起来的效果:
scala> withDefault(Some(1101))
res8: Int = 1101
scala> withDefault(None)
res9: Int = 0
这套机制对于 Akka 这个 actor 类库而言十分有用,因为有了它, Akka 可以用一组 case 来定义它的 receive 方法。
还有另一点值得我们注意:通过 case 序列得到的是一个偏函数(partial function)。如果我们将这样一个函数应用到它不支持的值上,它会产生一个运行时异常。
例如,这里有一个返回整数列表中第二个元素的偏函数。在编译时,编译器会正确地发出警告,我们的匹配并不全面。
如果传入一个三元素列表,这个函数会成功执行,不过传入空列表就没那么幸运了:
如果想检查某个偏函数是否对某个入参有定义,必须首先告诉编译器你知道你要处理的是偏函数。List[Int] => Int
这个类型涵盖了所有从整数列表到整数的函数,不论这个函数是偏函数还是全函数。
仅涵盖从整数列表到整数的偏函数的类型写作 PartialFunction[List[Int], Int]
。我们重新写一遍 secondEle 函数,这次用偏函数的类型声明。偏函数定义了一个 isDefinedAt 方法,可以用来检查该函数是否对某个特定的值有定义。
偏函数的典型用例是模式匹配函数字面量,就像前面这个例子。事实上,这样的表达式会被 Scala 编译器翻译成偏函数,这样的翻译发生了两次:一次是实现真正的函数;另一次是测试这个函数是否对指定值有定义。
举例来说,函数字面量 { case x::y::_ => y }
将被翻译成如下的偏函数值:
new PartialFunction[List[Int], Int] {
def apply(xs: List[Int]) = xs match {
case x :: y :: _ => y
}
override def isDefinedAt(x: List[Int]): Boolean = x match {
case x :: y :: _ => true
case _ => false
}
}
只要函数字面量声明的类型是 PartialFunction,这样的翻译就会生效。如果声明的类型只是 Function1 或没有声明,那么函数字面量对应的就是一个全函数(complete function)。
4.3 for 表达式中的模式#
object VarDeclareAndForMatchTest {
def main(args: Array[String]): Unit = {
val map = Map("A" -> 1, "B" -> 0, "C" -> 3)
for ((k, v) <- map) {
println(k + " -> " + v)
}
// 只遍历出 value=0 的 KV,其他过滤掉
for ((k, 0) <- map) {
println(k + " --> " + 0)
}
for ((k, v) <- map if v == 0) {
println(k + " ---> " + v)
}
// 生成的值当中那些不能匹配给定模式的值会被直接丢弃
val results = List(Some("apple"), None, Some("orange"))
for (Some(fruit) <- results) println(fruit)
}
}
5. 样例(模版)类/密封类#
5.1 样例类·说明#
在 Scala 中「样例类」是一中特殊的类,可用于模式匹配。 case class 是多例的,后面必须要跟构造参数,没有就必须写成 ()
,case object 是单例的。
package io.tree6x7.mymatch.caseclz
abstract class Amount
case class Dollar(value: Double) extends Amount
case class Currency(value: Double, unit: String) extends Amount
case object NoAmount extends Amount
反编译看下样例类的代码:
当一个类被声名为 case class 的时候,Scala 会帮助我们做下面几件事情:
- 会添加一个跟类同名的工厂方法:即自动创建伴生对象,同时在里面给我们实现子 apply 方法,使得我们在使用的时候可以不直接显示地 new 对象;还会帮我们实现 unapply 方法,从而可以将 case class 应用于模式匹配。
- 参数列表中的参数都隐式地获得了 一个 val 前缀,因此它们会被当作字段处理。
- 编译器会帮我们以“自然”的方式实现 toString、 hashCode 和 equals 方法。
- 编译器还会添加一个 copy 方法用于制作修改过的拷贝。
5.2 样例类·实践#
(1)当我们有一个类型为 Amount 的对象时,可以用模式匹配来匹配它的类型,并将属性值绑定到变量,即:把样例类对象的属性值提取到某个变量。
object CaseClassTest {
def main(args: Array[String]): Unit = {
for (amt <- Array(Dollar(1101.0), Currency(6677.0, "RMB"), NoAmount)) {
val result = amt match {
case Dollar(v) => "$" + v
case Currency(v, u) => v + " " + u
case NoAmount => "-NO-"
}
// Dollar(1101.0): $1101.0
// Currency(6677.0,RMB): 6677.0 RMB
// NoAmount: -NO-
println(amt + ": " + result)
}
}
}
abstract class Amount
case class Dollar(value: Double) extends Amount
case class Currency(value: Double, unit: String) extends Amount
case object NoAmount extends Amount
(2)样例类的 copy 方法和带名参数:copy 创建一个与现有对象值相同的新对象,并可以通过带名参数来修改某些属性。
object CaseClassTest {
def main(args: Array[String]): Unit = {
val amt1 = Currency(13.67, "RMB")
val amt2 = amt1.copy() // 创建了一个新的对象且属性值一样
val amt3 = amt1.copy(value = 11.01) // 创建了一个新对象,但修改了 value 属性
val amt4 = amt1.copy(unit = "英镑") // 创建了一个新对象,但修改了 unit 属性
println(amt2) // Currency(13.67,RMB)
println(amt3) // Currency(11.01,RMB)
println(amt4) // Currency(13.67,英镑)
}
}
abstract class Amount
case class Dollar(value: Double) extends Amount
case class Currency(value: Double, unit: String) extends Amount
case object NoAmount extends Amount
5.3 密封类#
如果想让 case 类的所有子类都必须在声明该类的源文件中定义,可以将样例类的通用超类声明为 sealed,这个超类称之为密封类(密封就是不能在其他文件中定义子类)。
每当我们编写一个模式匹配时,都需要确保完整地覆盖了所有可能的 case。有时候可以通过在末尾添加一个默认 case 来做到,不过这仅限于有合理兜底的场合。如果没有这样的默认行为,我们如何确信 自己覆盖了所有的场景呢?
我们可以寻求 Scala 编译器的帮助,帮我们检测出 match 表达式中缺失的模式组合。为了做到这一点,编译器需要分辨出可能的 case 有哪些。一般来说,在 Scala 中这是不可能的,因为新的样例类随时随地都能被定义出来。
解决这个问题的手段是将这些样例类的超类标记为密封(sealed)的。密封类除在同一个文件中定义的子类之外,不能添加新的子类。这一点对于模式匹配而言十分有用,因为这样一来我们就只需要关心那些已知的样例类。不仅如此,我们还因此获得了更好的编译器支持。如果我们对继承自密封类的样例类做匹配,编译器会用警告消息标示出缺失的模式组合。
如果你的类打算被用于模式匹配,那么你应该考虑将它们做成密封类。只需要在类继承关系的顶部那个类的类名前面加上 sealed 关键字。
6. 匹配嵌套结构#
操作原理类似于正则表达式。通过「案例-商品捆绑打折出售」进行说明。
现在有一些商品,请使用 Scala 设计相关的样例类,完成商品捆绑打折出售。要求:
- 商品捆绑可以是单个商品,也可以是多个商品
- 打折时按照折扣 x 元进行设计
- 统计出所有捆绑商品打折后的最终价格
前置说明:
object RecursionTest {
def main(args: Array[String]): Unit = {
// 创建样例类
abstract class Item
case class Book(description: String, price: Double) extends Item
case class Bundle(description: String, discount: Double, item: Item*) extends Item
// 创建嵌套结构
val sale = Bundle("书籍", 10, Book("狐狸在说什么", 40), Bundle("文学作品", 20, Book("《厌女》", 80), Book("《鼠疫》", 30)))
// 1. 进行对象匹配时,不想接受某些值,则使用 _ 忽略即可,_* 表示所有
val res = sale match {
case Bundle(_, _, Book(e, _), _*) => e
}
println(res) // 狐狸在说什么
// 2. 通过 @ 表示法将嵌套的值绑定到变量。_* 绑定剩余 Item 到 rest
val res2 = sale match {
case Bundle(_, _, b@Book(_, _), s@_*) => (b, s)
}
// res2=(Book(狐狸在说什么,40.0),WrappedArray(Bundle(文学作品,20.0,WrappedArray(Book(《厌女》,80.0), Book(《鼠疫》,30.0)))))
// b=Book(狐狸在说什么,40.0)
// s=WrappedArray(Bundle(文学作品,20.0,WrappedArray(Book(《厌女》,80.0), Book(《鼠疫》,30.0))))
println(s"res2=$res2\nb=${res2._1}\ns=${res2._2}")
// 3. 不使用 _* 绑定剩余 Item 到 rest
val res3 = sale match {
// 因为没有使用 _*,即明确说明没有多个 Bundle,所以返回的 rest 就不是 WrappedArray 了
case Bundle(_, _, art@Book(_, _), rest) => (art, rest)
}
// (Book(狐狸在说什么,40.0),Bundle(文学作品,20.0,WrappedArray(Book(《厌女》,80.0), Book(《鼠疫》,30.0))))
println(res3)
}
}
递归计算金额函数:
def calcPrice(it: Item): Double = {
it match {
case Book(_, p) => p
// 将 its 中每个元素传递到 calcPrice 中
case Bundle(_, disc, its@_*) => its.map(calcPrice).sum - disc
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?