Scala3 中的类型类:初学者指南 |分类帐

Scala3 中的类型类:初学者指南 |分类帐

Scala3 中的类型类:初学者指南 |账本柏拉图区块链数据智能。垂直搜索。人工智能。

本文档面向 Scala3 初学者,他们已经精通 Scala 语言,但对所有“implicits` 以及代码中的参数化特征。

本文档解释了原因、方式、地点和时间 类型类别 (TC).

阅读本文档后,初学者 Scala3 开发人员将获得扎实的使用知识并深入了解 ScalaXNUMX 的源代码 很多 Scala 库并开始编写惯用的 Scala 代码。

让我们从原因开始……

表达问题

1998年, 菲利普·瓦德勒表示 “表达问题是老问题的新名称”。这是软件可扩展性的问题。根据Wadler先生的写作,表达问题的解决必须遵守以下规则:

  • 规则 1:允许实施 现有行为 (想想 Scala 特性)应用于 新的表述 (想想一个案例类)
  • 规则 2:允许实施 新行为 应用于 现有的交涉
  • 规则3:不得危害 类型安全
  • 规则 4:不必重新编译 现有代码

解决这个问题将是本文的主旨。

规则 1:在新表示上实施现有行为

任何面向对象的语言都有针对规则 1 的内置解决方案 亚型多态性。您可以安全地实施任何`trait` 定义在对 ` 的依赖中class` 在您自己的代码中,无需重新编译依赖项。让我们看看实际效果:

斯卡拉

def todo = 42
type Height = Int
type Block = Int

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo


object Lib2:
 import Lib1.*

 case class Polkadot() extends Blockchain:
 override def getBlock(height: Height): Block = todo

val eth = Lib1.Ethereum()
val btc = Lib1.Bitcoin()
val dot = Lib2.Polkadot()

在这个虚构的例子中,库`Lib1`(第 5 行)定义了一个特征 `Blockchain`(第 6 行)及其 2 个实现(第 9 行和第 12 行)。 `Lib1` 在本文档的所有内容中将保持不变(执行规则 4)。

`Lib2`(第 15 行)实现现有行为 `Blockchain`在新班级`Polkadot`(规则 1)以类型安全(规则 3)的方式,无需重新编译 `Lib1`(规则 4)。 

规则 2:实施应用于现有表示的新行为

让我们想象一下‘Lib2`我们想要一种新的行为`lastBlock` 专门针对每个 ` 实施Blockchain`.

首先想到的是根据参数类型创建一个大开关。

斯卡拉

def todo = 42
type Height = Int
type Block = Int

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo

object Lib2:
 import Lib1.*

 case class Polkadot() extends Blockchain:
 override def getBlock(height: Height): Block = todo

 def lastBlock(blockchain: Blockchain): Block = blockchain match
 case _:Ethereum => todo
 case _:Bitcoin => todo
 case _:Polkadot => todo
 

object Lib3:
 import Lib1.*

 case class Polygon() extends Blockchain:
 override def getBlock(height: Height): Block = todo

import Lib1.*, Lib2.*, Lib3.*
println(lastBlock(Bitcoin()))
println(lastBlock(Ethereum()))
println(lastBlock(Polkadot()))
println(lastBlock(Polygon()))

该解决方案是基于类型的多态性的弱重新实现,该多态性已经内置于语言中!

`Lib1` 保持不变(请记住,在本文档中强制执行规则 4)。 

在`中实施的解决方案Lib2` 是 还可以 直到另一个区块链被引入`Lib3`。它违反了类型安全规则(规则 3),因为此代码在第 37 行运行时失败。并且修改 `Lib2` 将违反规则 4。

另一个解决方案是使用`extension`.

斯卡拉

def todo = 42
type Height = Int
type Block = Int

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo

object Lib2:
 import Lib1.*

 case class Polkadot() extends Blockchain:
 override def getBlock(height: Height): Block = todo

 def lastBlock(): Block = todo

 extension (eth: Ethereum) def lastBlock(): Block = todo

 extension (btc: Bitcoin) def lastBlock(): Block = todo

import Lib1.*, Lib2.*
println(Bitcoin().lastBlock())
println(Ethereum().lastBlock())
println(Polkadot().lastBlock())

def polymorphic(blockchain: Blockchain) =
 // blockchain.lastBlock()
 ???

`Lib1` 保持不变(在整个文档中执行规则 4)。 

`Lib2` 定义其类型的行为(第 21 行)和现有类型的“扩展”(第 23 和 25 行)。

第 28-30 行,新行为可以在每个类中使用。 

但是没有办法以多态方式调用这个新行为(第 32 行)。任何这样做的尝试都会导致编译错误(第 33 行)或基于类型的切换。 

第 2 条规则很棘手。我们尝试用我们自己的多态性定义和“扩展”技巧来实现它。这很奇怪。

有一个缺失的部分叫做 临时多态性: 能够根据类型安全地调度行为实现,无论行为和类型是在何处定义的。输入 类型类 格局。

类型类模式

Type Class(简称TC)模式配方有3个步骤。 

  1. 定义新行为
  2. 实施行为
  3. 使用行为

在下面的部分中,我以最直接的方式实现 TC 模式。它冗长、笨重且不切实际。但请稍等,这些警告将在文档中进一步得到解决。

1. 定义新行为
斯卡拉

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

`Lib1` 再次保持原样。

新行为 is TC 由特质具体化。特征中定义的功能是应用该行为某些方面的一种方法。

参数`A` 代表我们想要应用行为的类型,它们是 ` 的子类型Blockchain` 在我们的例子中。

一些备注:

  • 如果需要,参数化类型`A` 可以进一步受到 Scala 类型系统的约束。例如,我们可以强制执行`A` 成为一个 `Blockchain`. 
  • 此外,TC 中还可以声明更多的函数。
  • 最后,每个函数可能有更多的任意参数。

但为了可读性,让我们保持简单。

2. 实施行为
斯卡拉

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 val ethereumLastBlock = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 val bitcoinLastBlock = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

对于每种类型新的 `LastBlock` 行为是预期的,有该行为的特定实例。 

`Ethereum` 实现行 22 是根据 ` 计算得出的eth` 实例作为参数传递。 

实施`LastBlock` 为 `Bitcoin` 第 25 行是使用非托管 IO 实现的,并且不使用其参数。

所以,`Lib2` 实现新行为 `LastBlock` 为 `Lib1` 类。

3.使用行为
斯卡拉

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 val ethereumLastBlock = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 val bitcoinLastBlock = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A, behavior: LastBlock[A]) =
 behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2), ethereumLastBlock))
println(useLastBlock(Bitcoin(), bitcoinLastBlock))

第 30 行`useLastBlock` 使用 ` 的实例A` 和 `LastBlock` 为该实例定义的行为。

第 33 行`useLastBlock` 用 ` 的实例调用Ethereum` 以及 ` 的实现LastBlock` 定义于 `Lib2`。请注意,可以传递`的任何替代实现LastBlock[A]`(想想 依赖注入).

`useLastBlock` 是表征(实际的 A)与其行为之间的粘合剂。数据和行为分离,这就是函数式编程所提倡的。

讨论

我们回顾一下表达式问题的规则:

  • 规则 1:允许实施 现有行为  应用于 新班级
  • 规则 2:允许实施 新行为 应用于 现有课程
  • 规则3:不得危害 类型安全
  • 规则 4:不必重新编译 现有代码

规则 1 可以通过子类型多态性立即解决。

刚刚提出的 TC 模式(参见前面的屏幕截图)解决了规则 2。它是类型安全的(规则 3),我们从未触及`Lib1`(规则 4)。 

然而,由于以下几个原因,使用它是不切实际的:

  • 第 33-34 行我们必须显式地沿其实例传递行为。这是额外的开销。我们应该只写`useLastBlock(Bitcoin())`.
  • 第 31 行的语法不常见。我们宁愿写一个简洁且更面向对象的`instance.lastBlock()`声明。

让我们重点介绍一些用于 TC 实际使用的 Scala 功能。 

增强的开发人员体验

Scala 拥有一组独特的功能和语法糖,使 TC 成为开发人员真正愉快的体验。

隐式

隐式作用域是一种在编译时解析的特殊作用域,其中只能存在给定类型的一个实例。 

程序使用`将实例放入隐式作用域中given` 关键字。或者,程序可以使用关键字“从隐式作用域中检索实例”using`.

隐式作用域在编译时解析,有已知的方法可以在运行时动态更改它。如果程序编译,则隐式作用域被解析。在运行时,使用它们的隐式实例是不可能丢失的。唯一可能的混乱可能来自使用错误的隐式实例,但这个问题留给了椅子和键盘之间的生物。

它与全局范围不同,因为: 

  1. 这是根据上下文解决的。程序的两个位置可以在隐式作用域中使用相同给定类型的实例,但这两个实例可能不同。
  2. 在幕后,代码将隐式参数函数传递给函数,直到达到隐式用法。它不使用全局内存空间。

回到类型类!让我们举一个完全相同的例子。

斯卡拉

def todo = 42
type Height = Int
type Block = Int
def http(uri: String): Block = todo

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo

`Lib1` 与我们之前定义的未修改代码相同。 

斯卡拉

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 given ethereumLastBlock:LastBlock[Ethereum] = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given bitcoinLastBlock:LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A)(using behavior: LastBlock[A]) =
 behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2)))
println(useLastBlock(Bitcoin()))

第 19 行一个新行为`LastBlock` 被定义,就像我们之前所做的那样。

第 22 行和第 25 行,`val` 被替换为 `given`。 ` 的两种实现LastBlock` 被放入隐式作用域中。

第 31 行`useLastBlock` 声明行为 `LastBlock` 作为隐式参数。编译器解析`的适当实例LastBlock` 来自调用者位置上下文的隐式作用域(第 33 和 34 行)。第 28 行从 `Lib2`,包括隐式范围。因此,编译器将第 22 行和第 25 行定义的实例作为 ` 的最后一个参数传递useLastBlock`. 

作为库用户,使用类型类比以前更容易。第 34 和 35 行开发人员只需确保该行为的实例被注入到隐式作用域中(这可以只是一个`import`)。如果隐式不是`given` 其中代码是 `using` 它,编译器告诉他。

Scala 的隐式简化了传递类实例及其行为实例的任务。

隐性糖

之前代码的第22行和25行可以进一步改进!让我们迭代一下 TC 的实现。

斯卡拉

given LastBlock[Ethereum] = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

第22行和25行,如果实例的名称不用,可以省略。

斯卡拉


 given LastBlock[Ethereum] with
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] with
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

第22行和第25行,类型的重复可以用`替换with` 关键字。

斯卡拉

given LastBlock[Ethereum] = _.lastBlock

 given LastBlock[Bitcoin] = _ => http("http://bitcoin/last")

因为我们使用的是包含单个函数的退化特征,所以 IDE 可能会建议使用 SAM 表达式来简化代码。尽管正确,但我认为这不是 SAM 的正确使用方式,除非您只是随意地打高尔夫球。

Scala 提供语法糖来简化语法,消除不必要的命名、声明和类型冗余。

延期

明智地使用,`extension` 机制可以简化使用类型类的语法。

斯卡拉

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 given LastBlock[Ethereum] with
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] with
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

 extension[A](instance: A)
 def lastBlock(using tc: LastBlock[A]) = tc.lastBlock(instance)

import Lib1.*, Lib2.*

println(Ethereum(lastBlock = 2).lastBlock)
println(Bitcoin().lastBlock)

第 28-29 行通用扩展方法 `lastBlock` 是为任何 ` 定义的A` 带有一个 `LastBlock` 隐式作用域中的 TC 参数。

第 33-34 行扩展利用面向对象的语法来使用 TC。

斯卡拉

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 given LastBlock[Ethereum] with
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] with
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

 extension[A](instance: A)(using tc: LastBlock[A])
 def lastBlock = tc.lastBlock(instance)
 def penultimateBlock = tc.lastBlock(instance) - 1

import Lib1.*, Lib2.*

val eth = Ethereum(lastBlock = 2)
println(eth.lastBlock)
println(eth.penultimateBlock)

val btc = Bitcoin()
println(btc.lastBlock)
println(btc.penultimateBlock)

第28行,也可以为整个分机定义TC参数以避免重复。第 30 行我们在扩展中重用 TC 来定义`penultimateBlock` (尽管它可以在 `LastBlock` 直接特质)

当使用 TC 时,奇迹就会发生。这种表达感觉更加自然,给人一种行为“lastBlock` 与实例合并。

带TC的通用型
斯卡拉

import Lib1.*, Lib2.*

def useLastBlock1[A](instance: A)(using LastBlock[A]) = instance.lastBlock

def useLastBlock2[A: LastBlock](instance: A) = instance.lastBlock

val eth = Ethereum(lastBlock = 2)
assert(useLastBlock1(eth) == useLastBlock2(eth))

第 34 行该函数使用隐式 TC。请注意,如果不需要该名称,则无需为 TC 命名。

TC 模式的使用如此广泛,以至于有一种通用类型语法来表达“具有隐式行为的类型”。第 36 行语法是前一个语法(第 34 行)的更简洁的替代方案。它避免专门声明未命名的隐式 TC 参数。

开发者体验部分到此结束。我们已经看到,当使用和定义 TC 时,扩展、隐式和一些语法糖如何提供更简洁的语法。

自动推导

许多 Scala 库都使用 TC,让程序员在自己的代码库中实现它们。

例如 Circe(一个 json 反序列化库)使用 TC `Encoder[T]`和`Decoder[T]` 供程序员在其代码库中实现。一旦实施,库的整个范围都可以使用。 

TC 的这些实现通常是 面向数据的映射器。它们不需要任何业务逻辑,编写起来很无聊,并且维护与案例类同步的负担。

在这种情况下,这些库提供了所谓的 自动 推导或 半自动 推导。例如参见喀耳刻 自动 半自动 推导。通过半自动派生,程序员可以使用一些次要语法来声明类型类的实例,而自动派生除了导入之外不需要任何代码修改。

在底层,在编译时,通用宏内省 类型 作为纯数据结构并为图书馆用户生成 TC[T]。 

一般派生 TC 非常常见,因此 Scala 为此引入了一个完整的工具箱。尽管这是使用派生的 Scala 3 方式,但库文档并不总是宣传此方法。

斯卡拉

object GenericLib:

 trait Named[A]:
 def blockchainName(instance: A): String

 object Named:
 import scala.deriving.*

 inline final def derived[A](using inline m: Mirror.Of[A]): Named[A] =
 val nameOfType: String = inline m match
 case p: Mirror.ProductOf[A] => compiletime.constValue[p.MirroredLabel]
 case _ => compiletime.error("Not a product")
 new Named[A]:
 override def blockchainName(instance: A):String = nameOfType.toLowerCase

 extension[A] (instance: A)(using tc: Named[A])
 def blockchainName = tc.blockchainName(instance)

import Lib1.*, GenericLib.*

case class Polkadot() derives Named
given Named[Bitcoin] = Named.derived
given Named[Ethereum] = Named.derived

println(Ethereum(lastBlock = 2).blockchainName)
println(Bitcoin().blockchainName)
println(Polkadot().blockchainName)

18号线新TC`Named` 被引入。严格来说,这个TC与区块链业务无关。其目的是根据案例类的名称来命名区块链。

首先关注第 36-38 行的定义。派生 TC 有 2 种语法:

  1. 第 36 行 TC 实例可以直接在案例类上定义,使用 `derives` 关键字。编译器在底层生成一个给定的`Named` 中的实例Polkadot` 伴随对象。
  2. 第 37 和 38 行,类型类实例在预先存在的类上通过 ` 给出TC.derived

第 31 行定义了通用扩展(请参阅前面的部分)和 `blockchainName` 是自然使用的。  

`derives` 关键字需要一个格式为 ` 的方法inline def derived[T](using Mirror.Of[T]): TC[T] = ???` 这是第 24 行定义的。我不会深入解释代码的作用。概括地说:

  • `inline def` 定义一个宏
  • `Mirror` 是用于内省类型的工具箱的一部分。镜像有不同种类,第26行代码重点是`Product` 镜子(案例类是一种产品)。第 27 行,如果程序员尝试导出不是 ` 的东西Product`,代码无法编译。
  • `Mirror` 包含其他类型。其中之一,`MirrorLabel`, 是包含类型名称的字符串。该值用于`的第 29 行的实现中。Named` TC。

TC 作者可以使用元编程来提供一般生成给定类型的 TC 实例的函数。程序员可以使用专用库 API 或 Scala 派生工具为其代码创建实例。

无论您需要通用代码还是特定代码来实现 TC,每种情况都有一个解决方案。 

所有好处的总结

  • 解决了表达问题
    • 新类型可以通过传统特征继承来实现现有行为
    • 新的行为可以在现有类型上实现
  • 关注点分离
    • 该代码未被破坏且易于删除。 TC 将数据和行为分开,这是函数式编程的座右铭。
  • 它是安全的
    • 它是类型安全的,因为它不依赖于内省。它避免了涉及类型的大模式匹配。如果您遇到自己编写此类代码,您可能会发现 TC 模式非常适合的情况。
    • 隐式机制是编译安全的!如果编译时缺少实例,则代码将无法编译。运行时并不奇怪。
  • 它带来了临时多态性
    • 传统的面向对象编程中通常缺少临时多态性。
    • 通过临时多态性,开发人员可以为各种不相关的类型实现相同的行为,而无需使用传统的子类型(耦合代码)
  • 依赖注入变得容易
    • TC 实例可以根据里氏替换原则进行更改。 
    • 当组件依赖于 TC 时,可以轻松注入模拟的 TC 以进行测试。 

计数器指示

每把锤子都是针对一系列问题而设计的。

类型类用于解决行为问题,不得用于数据继承。为此目的使用组合。

通常的子类型更简单。如果您拥有代码库并且不以可扩展性为目标,那么类型类可能有点过分了。

例如,在 Scala 核心中,有一个 `Numeric` 类型类:

斯卡拉

trait Numeric[T] extends Ordering[T] {
 def plus(x: T, y: T): T
 def minus(x: T, y: T): T
 def times(x: T, y: T): T

使用这样的类型类确实很有意义,因为它不仅允许在 Scala 中嵌入的类型(Int、BigInt 等)上重用代数算法,而且还允许在用户定义的类型(a `ComplexNumber` 例如)。

另一方面,Scala 集合的实现主要使用子类型而不是类型类。这种设计之所以有意义有几个原因:

  • 集合 API 应该是完整且稳定的。它通过实现继承的特征公开常见行为。高度可扩展并不是这里的特定目标。
  • 它必须简单易用。 TC 增加了最终用户程序员的心理负担。
  • TC 也可能会产生少量的性能开销。这对于集合 API 可能至关重要。
  • 尽管如此,集合 API 仍然可以通过第三方库定义的新 TC 进行扩展。

结论

我们已经看到,TC 是一个解决大问题的简单模式。得益于 Scala 丰富的语法,TC 模式可以通过多种方式实现和使用。 TC 模式符合函数式编程范式,是构建干净架构的绝佳工具。没有灵丹妙药,TC 模式必须在适合时应用。

希望您通过阅读本文档获得了知识。 

代码可在 https://github.com/jprudent/type-class-article。如果您有任何问题或意见,请与我联系。如果需要,您可以使用存储库中的问题或代码注释。


杰罗姆·普鲁登特

软件工程师

时间戳记:

更多来自 莱杰