【Scala系列】上下文参数一探究竟

【Scala系列】上下文参数一探究竟

阅读须知

  1. 本文所有概念和代码都基于Scala3.x版本,其中部分代码在Scala2.x中无法使用。
  2. 阅读本文需要了解基础的Scala语法,以及函数的柯里化。
  3. 本文所有代码均已经过Scala3.4.2编译和运行验证。

什么是上下文参数

上下文参数,英文名context parameter,Scala提供这一特性以解决调用函数时入参样板代码过多的问题。这个特性通过标识特定入参为上下文参数,以启用编译器自动填充合适的参数最终完成函数调用。

模拟场景实例

代码文件结构先贴到最前面:

|contextparameter
|--BuzNotify.scala
|--Entity.scala
|--EntryPoint.scala

考虑这样一个场景:有一个通知组件,它支持以短信、邮件或企微通知的方式,给指定目标发送通知内容。我们可以先定义一套密封类表示该组件支持的渠道,以及一个通讯名册,将之定义到Entity.scala中:

// Entity.scala
// 可以使用的通知渠道
sealed trait NotifyChannel() {
  val describe: String = "未知渠道"
}
object SMS extends NotifyChannel {  // 短信
  override val describe: String = "短信渠道"
}
object Email extends NotifyChannel {  // 邮件
  override val describe: String = "邮件渠道"
}
object WxWorkGroup extends NotifyChannel {  // 企微群组
  override val describe: String = "企微群组渠道"
}

/**
 * 通讯名册
 * @param id              id标识
 * @param desc            通讯名册的描述
 * @param book            通讯名册。其中的元组定义为:姓名-通讯码-联系渠道
 */
case class ContactBook(id: String, desc: String, book: Seq[(String, String, NotifyChannel)])

我们支持3种通知:短信渠道、邮件渠道和企微群组渠道;我们有了一个通讯名册,定义了名册标识符、描述、包括姓名和联系方式在内的名册列表。

这个组件的核心通知方法,我们单独放在BuzNotify.scala中,以打印的方式简单模拟其真实实现,它应该是这样的:

// BuzNotify.scala
/**
 * @param content        通知内容
 * @param to             通讯名册
 * @param targetChannels 目标通知渠道
 * @return
 */
def notify1(content: String, to: ContactBook, targetChannels: Set[NotifyChannel]): Boolean =
  if targetChannels.isEmpty then return false
  to.book filter { t => targetChannels.contains(t._3)} foreach { (contactName, contactDest, channel) =>
    println(s"通过[${channel.describe}]给[${contactName}]发送通知\n通知目的地为${contactDest}\n通知内容为:${content}\n")
  }
  true

可以看到,我们在notify1方法中,接收三个参数:content-通知内容、to-要通知的通讯手册和targetChannels-通知的目标渠道。

这样一来,我们就可以尝试新建一个通讯名册,并对这个名册进行消息通知了,我将这段代码放置到EntryPoint.scala文件中:

// EntryPoint.scala
val buzDepContactBook1 = ContactBook(
  "1",
  "商务部门领导名册",
  Seq(("领导A", "12345678900", SMS), ("领导B", "19987654321", SMS), ("领导C", "leaderC@foo.bar", Email)),
  Set(SMS, Email)
)

def invoke1(): Unit =
  // 经过一些业务后,给领导们发通知
  notify1(
    "今天晚上8点线上会议,请各位领导留意会议APP的通知,务必准时参加,谢谢~",
    buzDepContactBook1,
    Set(SMS)
  )
  // 又经过一些业务后,继续通知
  notify1(
    "A同志 今天晚上无故缺席会议,特此通报批评,希望各位领导遵守公司规章制度,谢谢~",
    buzDepContactBook1,
    Set(SMS)
  )

@main def main(args: String*): Unit =
  invoke1()

通常来说,使用notify1函数需要完整传入三个参数,比如上述例子中的会议通知调用和通报批评调用。可以看到,使用它的时候,我们会重复的传入buzDepContactBook1实例和一个Set(SMS)集合,未免稍显啰嗦。此时,我们就可以请出本文的主角登场:上下文参数

先看看改造后我们的调用,它变得简洁很多:

// BuzNotify.scala
/**
 * @param content        通知内容
 * @param to             通讯名册
 * @param targetChannels 目标通知渠道
 * @return
 */
def notify2(content: String)(using to: ContactBook, targetChannels: Set[NotifyChannel]): Boolean =
  if targetChannels.isEmpty then return false
  to.book filter { t => targetChannels.contains(t._3) } foreach { (contactName, contactDest, channel) =>
    println(s"通过[${channel.describe}]给[${contactName}]发送通知\n通知目的地为${contactDest}\n通知内容为:${content}\n")
  }
  true

// EntryPoint.scala
val buzDepContactBook1 = ContactBook(
  "1",
  "商务部门领导名册",
  Seq(("领导A", "12345678900", SMS), ("领导B", "19987654321", SMS), ("领导C", "leaderC@foo.bar", Email)),
  Set(SMS, Email)
)
// 在此作用域内赋予Destination类型一个默认上下文buzDepContactBook1
given ContactBook = buzDepContactBook1
// 在此作用域内赋予Set[NotifyChannel]类型一个默认上下文Set(SMS)
given Set[NotifyChannel] = Set(SMS)

def invoke2(): Unit =
  // 经过一些业务后,给领导们发通知
  notify2("今天晚上8点线上会议,请各位领导留意会议APP的通知,务必准时参加,谢谢~")         // 只传一个参数即可
  // 又经过一些业务后,继续通知
  notify2("A同志 今天晚上无故缺席会议,特此通报批评,希望各位领导遵守公司规章制度,谢谢~")  // 只传一个参数即可

@main def main(args: String*): Unit =
  invoke2()

这段代码中,我们定义了一个新的通知函数notify2,并在使用这个函数时,省略了由using标识的两个参数。其中的要点有:

  • 对原有参数列表使用柯里化进行分割,对比notify1函数而言,notify2中的参数列表变为了两组,其中一组是(content: String),另一组是(to: ContactBook, targetChannels: Set[NotifyChannel])
  • 对在业务中只需初始化一次而需多次传参的参数,使用using关键字标记它们为"上下文参数"。本例中,它们是to: ContactBook参数和targetChannels: Set[NotifyChannel]参数。为了便于演示,使用新的函数名notify2

[!NOTE]

具体本例来说,业务使用本函数时,通讯名册往往只需要初始化一次,而通知它们时,往往会多次重复调用函数来执行通知,因此,"通讯名册"这个参数在商务部门的通知业务中,就是典型的只需要初始化一次,而需要多次传参的参数,因此我们将之标记为上下文参数。"目标通知渠道"这个参数同理。

另外,描述中对to参数和targetChannels参数都带上了其类型,这不是啰嗦,而是因为上下文参数的推断过程与其参数类型高度相关。Scala编译器推断上下文参数时往往根据其类型查找适用的实例。

  • 在实际业务中,我们使用given关键字标记一个通讯名册的实例为上下文参数,标记一个通知渠道的集合(也就是Set[NotifyChannel]类型)为上下文参数。
  • 调用新函数notify2时,只需传入一个content参数,剩余的两个上下文参数将由Scala编译器在编译期间自动推导填入。

最后,我们跑一下EntryPoint.scala文件中的main函数,将得到如下结果:

通过[短信渠道]给[领导A]发送通知
通知目的地为12345678900
通知内容为:今天晚上8点线上会议,请各位领导留意会议APP的通知,务必准时参加,谢谢~

通过[短信渠道]给[领导B]发送通知
通知目的地为19987654321
通知内容为:今天晚上8点线上会议,请各位领导留意会议APP的通知,务必准时参加,谢谢~

通过[短信渠道]给[领导A]发送通知
通知目的地为12345678900
通知内容为:A同志 今天晚上无故缺席会议,特此通报批评,希望各位领导遵守公司规章制度,谢谢~

通过[短信渠道]给[领导B]发送通知
通知目的地为19987654321
通知内容为:A同志 今天晚上无故缺席会议,特此通报批评,希望各位领导遵守公司规章制度,谢谢~

上下文参数的高级用法:类型族、类型参数简写

使用上下文参数定义类型族

类型族值得单独再开一篇,本文简要提及,后续在专栏中我们继续深入这个神奇的特性(挖个坑)。

在此,我们对上文中的例子做一些改造进阶:如果要强制使用方只能使用包含可以发送的通讯码的通讯名录来发送通知(什么是不可发送的?比如,通讯名录中填写的是家庭住址,那它就是不可发送的通讯码),我们考虑泛化ContactBook类,并定义一个"可发送"特质,使得通讯录中只能传入"可发送"的通讯码,方法如下:

// Entity.scala
/**
 * 通讯名册-泛化版
 *
 * @param id   id标识
 * @param desc 目的地的描述
 * @param book 通讯名册。其中的元组定义为:姓名-通讯码-联系渠道
 * @tparam A 通讯码的类型
 */
case class ContactBookGn[A](id: String, desc: String, book: Seq[(String, A, NotifyChannel)])(using s: Sendable[A])

/**
 * 可发送特质
 * @tparam T 可发送的类型
 */
trait Sendable[T] {
  def send(content: String, sendDestCode: T): Unit
}

// 手机通讯码
class MobileContactCode(val mobile: String)
object MobileContactCode {
  // 给手机通讯码提供一个Sendable[MobileContactCode]类型的上下文参数
  given Sendable[MobileContactCode] with
    def send(content: String, sendDestCode: MobileContactCode): Unit =
      println(s"给通讯码${sendDestCode.mobile}发送短信通知\n通知内容:${content}\n")
}
// 邮件通讯码
class EmailContactCode(val email: String)
object EmailContactCode {
  // 给邮件通讯码提供一个Sendable[EmailContactCode]类型的上下文参数
  given Sendable[EmailContactCode] with
    def send(content: String, sendDestCode: EmailContactCode): Unit =
      println(s"给通讯码${sendDestCode.email}发送邮件通知\n通知内容:${content}\n")
}

上面的代码的要点是:

  • 定义了新的带类型参数的通讯名册-泛化版ContactBookGn[A],并为其指定一个上下文参数s: Sendable[A],这个写法的意思是,要实例化ContactBookGn[A],我们必须提供一个Sendable[A]的上下文。
  • 定义了一个"可发送"特质,它带有一个send抽象方法,以实现具体的发送功能。
  • 定义了两个通讯码,分别是手机通讯码MobileContactCode和邮件通讯码EmailContactCode;这两个通讯码的伴生实例分别提供了一个上下文实现,以此定义在不实现Sendable特质的情况下,仍然可以符合里氏替换原则。

[!NOTE]

里氏替换原则是说,如果S是T的子类型,对于S类型的任意对象,如果将他们看作是T类型的对象,则对象的行为也理应与期望的行为一致。

有一种更简单的解释:子类型(subtype)必须能够替换掉他们的基类型(base type)

同时,我们还需对通知函数进行类似的泛化和特质限制:

// BuzNotify.scala
/**
 * @param content        通知内容
 * @param to             通讯名册
 * @param targetChannels 目标通知渠道
 * @tparam A             能支持Sendable[A]的类型参数
 * @return
 */
def notify3Gn[A](content: String)(using to: ContactBookGn[A], targetChannels: Set[NotifyChannel])(using s: Sendable[A]): Boolean =
  if targetChannels.isEmpty then return false
  to.book filter { t => targetChannels.contains(t._3) } foreach { (contactName, contactDest, channel) =>
    s.send(content, contactDest)
  }
  true

这里定义一个新的函数notify3Gn[A],并在原先的基础上,添加另一组上下文参数:(using s: Sendable[A])。与通讯名册的定义类似,这里也使用s: Sendable[A]来限制类型参数A能传入的实际类型。除了这个改动外,我们还使用上下文参数s的方法send来代替原先裸露的发送实现。

好了,定义完这一切,我们可以尝试调用这个全新的版本:

// EntryPoint.scala
def invoke3Gn(): Unit =
  // 给出ContactBookGn[MobileContactCode]的上下文参数
  given ContactBookGn[MobileContactCode] = ContactBookGn[MobileContactCode]("2", "研发部领导手机号通讯名册", Seq(("领导X", MobileContactCode("19911223344"), SMS), ("领导Y", MobileContactCode("19955667788"), SMS)))
  notify3Gn("新版需求上线已完成,请研发部领导关注服务器告警通知")

@main def main(args: String*): Unit =
  invoke3Gn()

执行它会得到以下输出:

给通讯码19911223344发送短信通知
通知内容:新版需求上线已完成,请研发部领导关注服务器告警通知

给通讯码19955667788发送短信通知
通知内容:新版需求上线已完成,请研发部领导关注服务器告警通知

一切如预期般正常运行。

那么章节标题中的类型族去哪了?实际上,MobileContactCodeEmailContactCode就是符合Sendable[T]约束的类型族,它们的重要特点是,这两个类与"可发送"特质之间并无子类或超类的关系,却可以正常填入需要使用Sendable[T]的地方(即符合里氏替换),就好像这两个类就是Sendable[T]的子类一样。

给大家看一个编译不通过的例子,希望可以与上文的代码互相印证,加深大家对类型族的理解:

// Entity.scala
// 错误示例
val c = ContactBookGn[String]("2", "研发部领导手机号通讯名册", Seq(("领导X", "19911223344", SMS), ("领导Y", "19955667788", SMS)))
// 编译不通过:No given instance of type Sendable[String] was found for a context parameter of method apply in object ContactBookGn

这句话编译不通过,因为String既不是Sendable[T]的子类,也不是符合Sendable[T]约束的类型族。

上下文参数省略名称

这个章节的内容较为晦涩,且写出来之后对同事的心智负担也比较大,敬请读者在使用时慎之又慎。

由于上下文参数实际上根据类型来查找,因此given 实例名称using 参数名称中的名称并不是必须的,这一点实际上前文代码中也有提及,此处重复说明一下,它是这样的:

// EntryPoint.scala
// 可以不给实例名,直接匿名指定上下文参数
given ContactBook = ContactBook(
  "1",
  "商务部门领导名册",
  Seq(("领导A", "12345678900", SMS), ("领导B", "19987654321", SMS), ("领导C", "leaderC@foo.bar", Email)),
  Set(SMS, Email)
)
given Set[NotifyChannel] = Set(SMS)  // 此处写法实际上与前文相同

case class ContactBookGn[A](id: String, desc: String, book: Seq[(String, A, NotifyChannel)])(using Sendable[A])  // 这里就使用了省略上下文参数名的正确使用场景

还有更简单的写法:上下文绑定

在上文提及的ContactBookGn[A]notify3Gn[A]还有更简单的写法:

// Entity.scala
// 可以这样写。与原先的写法等效。
case class ContactBookGn[A: Sendable](id: String, desc: String, book: Seq[(String, A, NotifyChannel)])

// BuzNotify.scala
// 错误示例。
// 不能这样写,因为原先定义的上下文参数s: Sendable[A]是有用的,我们会在函数体中使用s.send()方法。
// 这个写法将省略上下文参数的实例名,导致我们不可能对send()方法进行调用。
def notify3Gn[A: Sendable](content: String)(using to: ContactBookGn[A], targetChannels: Set[NotifyChannel]): Boolean

我们可以使用[A: Sendable]来表示如下含义:类型参数A需要一个匿名上下文参数Sendable[A]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值