我可以晕吗? 受到通用类型编程的打击

哈Ha


上一次,我们描述了“ Has模式”,概述了其解决的问题,并编写了一些特定的实例:


 instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerConfig instance HasCronConfig AppConfig where getCronConfig = cronConfig 

看起来不错 这里会出现什么困难?


图片


好吧,让我们考虑一下我们可能还需要哪些其他实例。 首先,本身具有配置的具体类型是这些类型类的(简单)实现的良好候选者,这为我们提供了另外三个实例,其中每种方法都是通过id实现的,例如


 instance HasDbConfig DbConfig where getDbConfig = id 

它们使我们能够轻松编写独立于整个AppConfig单个测试或帮助程序实用程序。


这已经很无聊,但仍会继续。 可以想象,一些集成测试会检查一对模块的交互,而我们仍然不希望依赖于整个应用程序的配置,因此现在我们需要编写六个实例(每个类型两个),每个实例都将简化为fstsnd 。 例如,对于DbConfig


 instance HasDbConfig (DbConfig, b) where getDbConfig = fst instance HasDbConfig (a, DbConfig) where getDbConfig = snd 

恐怖片 希望我们永远不需要同时测试三个模块的操作-否则,您将不得不编写9个无聊的实例。 无论如何,我个人已经很不舒服,我宁愿花几个小时来自动执行此问题,也不希望花几分钟来编写额外的代码行。


此外,如果您对如何以一般方式解决此问题感兴趣,那么它是从属类型,以及最终看起来像Haskell-Welkom猫。


总结Has


首先,请注意,对于不同的环境,我们有不同的类。 这可能会妨碍制定通用解决方案,因此我们在单独的参数中取出环境:


 class Has part record where extract :: record -> part 

可以说, Has part record意味着可以从类型record的值中提取part类型的值。 用这些术语,我们好的旧式HasDbConfig变为Has DbConfig ,对于我们先前编写的其他类型类也是如此。 事实证明,这几乎是纯粹的语法变化,例如,上一篇文章中的一种函数的类型由


 doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ... 


 doSmthWithDbAndCron :: (MonadReader rm, Has DbConfig r, Has CronConfig r) => ... 

唯一的变化是在正确的位置有几个空格。


另外,我们在类型推断方面并没有损失太多:在实践中遇到的大多数情况下,计时器仍可以在周围的上下文中输出必要的extract返回值。


现在,我们不在乎特定的环境类型,让我们看看哪些记录可以为固定part实现“ Has part record类。 此任务具有良好的归纳结构:


  1. 每种类型都有它自己: Has record record方法很简单( extract = id )。
  2. 如果recordrec1rec2类型的rec1 ,则仅当Has part rec1Has part rec2时,才实现Has part record
  3. 如果recordrec1rec2类型的总和,则只有当Has part rec1Has part rec2 ,才Has part record 。 尽管在这种情况下这种情况的实际患病率并不明显,但出于完整性考虑,仍然值得一提。

因此,看起来我们已经制定了算法的草图,可以自动确定是否Has part recordpart实施了part recordrecord数据!


幸运的是,这种基于类型的归纳推理非常适合Haskell 泛型机制。 简而言之,泛型是Haskell中广义元编程的方法之一,这是由于观察到每种类型都是求和类型,乘积类型或具有一个字段的单构造基本类型。


我不会再编写有关泛型的教程,所以继续学习代码。


第一次尝试


我们将通过辅助类GHas HasGeneric实现的经典方法:


 class GHas part grecord where gextract :: grecord p -> part 

grecord是我们record类型的Generic表示形式。


GHas实现遵循我们上面提到的归纳结构:


 instance GHas record (K1 i record) where gextract (K1 x) = x instance GHas part record => GHas part (M1 it record) where gextract (M1 x) = gextract x instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

  1. K1对应于基本情况。
  2. M1我们在任务中不需要的泛型特定元数据,因此我们只需忽略它们并遍历它们。
  3. 产品类型l :*: r的第一个实例对应于产品的“左”部分具有我们需要的类型part的值的情况(可能是递归的)。
  4. 类似地,产品类型l :*: r的第二种情况对应于产品的“右侧”部分具有类型part的期望值(自然,也可能递归)的情况。

我们仅在这里支持产品类型。 我的主观印象是MonadReader和类似类的上下文中不经常使用金额,因此可以忽略它们以简化考虑。


此外,请注意每个 n一元类型乘积(a1, ..., an)可以表示为组成 n1(a1, (a2, (a3, (..., an)))) ,所以我允许自己将产品类型与对关联。


使用我们的GHas ,您可以为Has编写一个使用泛型的默认实现:


 class Has part record where extract :: record -> part default extract :: Generic record => record -> part extract = gextract . from 

做完了


还是不行


问题


如果我们尝试编译此代码,则即使没有尝试使用此实现(默认情况下也是如此)并报告那里的一些重叠实例,我们仍会看到它并没有解决问题。 更糟糕的是,这些实例在某些方面是相同的。 似乎是时候弄清楚Haskell中解析实例的机制如何工作了。


可以有


 instance context => Foo barPattern bazPattern where ... 

(顺便说一句, =>之后的东西称为实例头。)


读为


让我们需要为Foo bar baz选择一个实例。 如果满足context可以选择此实例,前提是barbaz对应于barPatternbazPattern

但是,这是一种误解,而恰恰相反:


让我们需要为Foo bar baz选择一个实例。 如果 barbaz对应于barPatternbazPattern ,那么我们选择此实例并将context添加到必须解析的常量列表中。

现在很明显问题出在哪里。 让我们仔细看看以下两个实例:


 instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

它们具有相同的实例头,所以难怪它们相交! 此外,它们都不比其他更具体。


此外,无法以某种方式优化这些实例,以使其不再重叠。 好吧,除了添加更多的GHas参数。


表现力类型急救!


解决该问题的方法是预先计算出我们感兴趣的值的“路径”,并使用该路径来指导实例的选择。


由于我们同意不支持求和类型,因此从字面上看,路径是产品类型的左转或右转序列(即,选择一对中的第一个或第二个成分),并在找到所需类型后以大的“ HERE”指针结尾。 我们这样写:


 data Path = L Path | R Path | Here deriving (Show) 

举个例子

请考虑以下类型:


 data DbConfig = DbConfig { dbAddress :: DbAddress , dbUsername :: Username , dbPassword :: Password } data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

来自AppConfig的路径的一些示例是什么?


  1. DbConfig L Here
  2. WebServerConfig R (L Here)R (L Here)
  3. CronConfig R (R Here)R (R Here)
  4. DbAddress L (L Here)

搜索所需类型的值可能是什么结果? 显而易见有两个选择:我们可以找到它,也可以找不到它。 但实际上,所有事情都有些复杂:我们可以找到这种类型的多个值。 显然,在这个有争议的案例中,最明智的行为也将是错误消息。 特定值的任何选择都将具有一定程度的随机性。


确实,请考虑我们的标准Web服务示例。 如果有人想要获取类型(Host, Port) ,那么它应该是数据库服务器的地址还是Web服务器的地址? 最好不要冒险。


无论如何,让我们用代码表达这一点:


 data MaybePath = NotFound | Conflict | Found Path deriving (Show) 

我们将NotFoundConflict分开,因为这些情况的处理方式根本不同:如果在我们产品类型的一个分支中获得NotFound ,则在其他某个分支中找到所需的值不会有任何损害,而任何分支中的Conflict立即意味着已满失败。


现在,我们考虑产品类型的一种特殊情况(按照我们的同意,我们将其视为对)。 如何在其中找到所需类型的值? 您可以在一对中的每个组件中递归运行搜索p2分别获得结果p1p2然后以某种方式将它们组合。


由于我们正在讨论的是在编译过程中发生的时间类实例的选择,因此我们实际上需要进行编译时计算,这些计算在Haskell中通过对类型的计算来表示(即使类型是通过使用DataKinds在Universe中提出的术语来DataKinds )。 因此,此类关于类型的函数表示为类型族:


 type family Combine p1 p2 where Combine ('Found path) 'NotFound = 'Found ('L path) Combine 'NotFound ('Found path) = 'Found ('R path) Combine 'NotFound 'NotFound = 'NotFound Combine _ _ = 'Conflict 

此函数代表几种情况:


  1. 如果其中一个递归搜索成功,而另一个NotFound ,则我们采用成功搜索的路径,并在正确的方向附加转弯。
  2. 如果两个递归搜索都以NotFound终止,那么显然整个搜索都以NotFound终止。
  3. 在任何其他情况下,我们都会得到Conflict

现在,我们将编写一个Tipe级函数来获取要查找的part ,并以Generic表示形式查找part ,并进行搜索:


 type family Search part (grecord :: k -> *) :: MaybePath where Search part (K1 _ part) = 'Found 'Here Search part (K1 _ other) = 'NotFound Search part (M1 _ _ x) = Search part x Search part (l :*: r) = Combine (Search part l) (Search part r) Search _ _ = 'NotFound 

请注意,我们得到的含义与我们先前对GHas尝试非常相似。 这是意料之中的,因为我们实际上是在重现我们试图通过时间类表达的算法。


GHas ,剩下的就是为该类添加一个附加参数,该参数负责前面找到的路径,并将用于选择特定实例:


 class GHas (path :: Path) part grecord where gextract :: Proxy path -> grecord p -> part 

我们还为gextract添加了一个附加参数,以便编译器可以为给定路径选择正确的实例(为此必须在函数签名中提及)。


现在编写实例非常简单:


 instance GHas 'Here record (K1 i record) where gextract _ (K1 x) = x instance GHas path part record => GHas path part (M1 it record) where gextract proxy (M1 x) = gextract proxy x instance GHas path part l => GHas ('L path) part (l :*: r) where gextract _ (l :*: _) = gextract (Proxy :: Proxy path) l instance GHas path part r => GHas ('R path) part (l :*: r) where gextract _ (_ :*: r) = gextract (Proxy :: Proxy path) r 

实际上,我们只是根据我们先前计算的路径中的path选择所需实例。


现在如何在Has类中编写我们的extract :: record -> part函数的default实现? 我们有几个条件:


  1. record必须实现Generic以便可以应用泛型机制,因此我们获得了Generic record
  2. Search函数应该在record找到part (或者在recordGeneric表示形式中,表示为Rep record )。 在代码中,这看起来有点不寻常: Search part (Rep record) ~ 'Found path 。 此记录表示限制,即Search part (Rep record)的结果Search part (Rep record)应等于'Found path某些path'Found path ”(实际上,这对我们很有趣)。
  3. 我们应该能够将GHaspart一起使用,从最后一步开始,它是recordpath的通用表示形式,它将变成GHas path part (Rep record)

我们将多次遇到最后两个常量,因此将它们放在单独的const同义词中很有用:


 type SuccessfulSearch part record path = (Search part (Rep record) ~ 'Found path, GHas path part (Rep record)) 

有了这个同义词,我们得到


 class Has part record where extract :: record -> part default extract :: forall path. (Generic record, SuccessfulSearch part record path) => record -> part extract = gextract (Proxy :: Proxy path) . from 

现在一切!


使用通用Has


为了了解所有这些情况,我们将为假人编写一些通用实例:


 instance SuccessfulSearch a (a0, a1) path => Has a (a0, a1) instance SuccessfulSearch a (a0, a1, a2) path => Has a (a0, a1, a2) instance SuccessfulSearch a (a0, a1, a2, a3) path => Has a (a0, a1, a2, a3) 

在此, SuccessfulSearch a (a0, ..., an) patha0, ..., an恰好在a0, ..., an之间发生的事实。


愿我们现在拥有美好的旧时光


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

并且我们要输出Has DbConfigHas WebServerConfigHas CronConfig 。 包含DeriveGenericDeriveAnyClass并添加正确的deriving声明就足够了:


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig, Has WebServerConfig, Has CronConfig) 

我们很幸运(或者我们有足够的见识)可以安排Has的参数,以便嵌套类型的名称首先出现,以便我们可以依赖DeriveAnyClass机制来减少DeriveAnyClass


安全第一


如果我们没有任何类型怎么办?


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig } deriving (Generic, Has CronConfig) 

不,我们在类型定义时遇到了一个错误:


 Spec.hs:35:24: error: • Couldn't match type ''NotFound' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has CronConfig AppConfig) | 35 | } deriving (Generic, Has CronConfig) | ^^^^^^^^^^^^^^ 

不是最友好的错误消息,但是即使从错误消息中您仍然可以了解问题所在:奇数频率NotFound奇数频率CronConfig


如果我们有几个相同类型的字段怎么办?


 data AppConfig = AppConfig { prodDbConfig :: DbConfig , qaDbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig) 

不,与预期的一样:


 Spec.hs:37:24: error: • Couldn't match type ''Conflict' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has DbConfig AppConfig) | 37 | } deriving (Generic, Has DbConfig) | ^^^^^^^^^^^^ 

一切似乎都很好。


总结一下


因此,我们将尝试简要地提出所提出的方法。


假设我们有某种类型的打字机,并且我们想根据一些递归规则自动显示其实例。 然后,我们可以避免歧义(如下所示:如果规则不重要且不适合解决实例的标准机制,则通常表达这些规则):


  1. 我们以归纳数据类型T的形式编码递归规则T
  2. 我们将在类型(以类型族的形式)上编写一个函数,以初步计算该类型T的值v (或者就Haskell而言,类型T类型v其中是我的从属类型),它描述了需要采取的具体步骤顺序。
  3. 使用此v作为Generic帮助程序的附加参数,可以确定现在与v值匹配的实例的特定顺序。

好吧,就是这样!


在以下文章中,我们将研究此方法的一些优雅扩展(以及优雅限制)。


哦是的 跟踪我们的概括顺序很有趣。


  1. Env -> Foo
  2. 不够笼统,请在Reader Env monad中进行总结。
  3. 不够通用,请使用MonadReader Env m重写。
  4. 不够通用,重写MonadReader rm, HasEnv r
  5. 不够通用, MonadReader rm, Has Env r编写MonadReader rm, Has Env r并添加泛型,以便编译器在那里执行所有操作。
  6. 现在是规范。

Source: https://habr.com/ru/post/zh-CN471052/


All Articles