哈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
单个测试或帮助程序实用程序。
这已经很无聊,但仍会继续。 可以想象,一些集成测试会检查一对模块的交互,而我们仍然不希望依赖于整个应用程序的配置,因此现在我们需要编写六个实例(每个类型两个),每个实例都将简化为fst
或snd
。 例如,对于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
类。 此任务具有良好的归纳结构:
- 每种类型都有它自己:
Has record record
方法很简单( extract = id
)。 - 如果
record
是rec1
和rec2
类型的rec1
,则仅当Has part rec1
或Has part rec2
时,才实现Has part record
。 - 如果
record
是rec1
和rec2
类型的总和,则只有当Has part rec1
和Has part rec2
,才Has part record
。 尽管在这种情况下这种情况的实际患病率并不明显,但出于完整性考虑,仍然值得一提。
因此,看起来我们已经制定了算法的草图,可以自动确定是否Has part record
为part
实施了part
record
并record
数据!
幸运的是,这种基于类型的归纳推理非常适合Haskell 泛型机制。 简而言之,泛型是Haskell中广义元编程的方法之一,这是由于观察到每种类型都是求和类型,乘积类型或具有一个字段的单构造基本类型。
我不会再编写有关泛型的教程,所以继续学习代码。
第一次尝试
我们将通过辅助类GHas
Has
的Generic
实现的经典方法:
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
K1
对应于基本情况。M1
我们在任务中不需要的泛型特定元数据,因此我们只需忽略它们并遍历它们。- 产品类型
l :*: r
的第一个实例对应于产品的“左”部分具有我们需要的类型part
的值的情况(可能是递归的)。 - 类似地,产品类型
l :*: r
的第二种情况对应于产品的“右侧”部分具有类型part
的期望值(自然,也可能递归)的情况。
我们仅在这里支持产品类型。 我的主观印象是MonadReader
和类似类的上下文中不经常使用金额,因此可以忽略它们以简化考虑。
此外,请注意每个 一元类型乘积(a1, ..., an)
可以表示为组成 对(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
, 则可以选择此实例,前提是bar
和baz
对应于barPattern
和bazPattern
。
但是,这是一种误解,而恰恰相反:
让我们需要为Foo bar baz
选择一个实例。 如果 bar
和baz
对应于barPattern
和bazPattern
,那么我们选择此实例并将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
的路径的一些示例是什么?
- 到
DbConfig
L Here
。 - 至
WebServerConfig
R (L Here)
为R (L Here)
。 - 至
CronConfig
R (R Here)
为R (R Here)
。 - 到
DbAddress
L (L Here)
。
搜索所需类型的值可能是什么结果? 显而易见有两个选择:我们可以找到它,也可以找不到它。 但实际上,所有事情都有些复杂:我们可以找到这种类型的多个值。 显然,在这个有争议的案例中,最明智的行为也将是错误消息。 特定值的任何选择都将具有一定程度的随机性。
确实,请考虑我们的标准Web服务示例。 如果有人想要获取类型(Host, Port)
,那么它应该是数据库服务器的地址还是Web服务器的地址? 最好不要冒险。
无论如何,让我们用代码表达这一点:
data MaybePath = NotFound | Conflict | Found Path deriving (Show)
我们将NotFound
和Conflict
分开,因为这些情况的处理方式根本不同:如果在我们产品类型的一个分支中获得NotFound
,则在其他某个分支中找到所需的值不会有任何损害,而任何分支中的Conflict
立即意味着已满失败。
现在,我们考虑产品类型的一种特殊情况(按照我们的同意,我们将其视为对)。 如何在其中找到所需类型的值? 您可以在一对中的每个组件中递归运行搜索p2
分别获得结果p1
和p2
然后以某种方式将它们组合。
由于我们正在讨论的是在编译过程中发生的时间类实例的选择,因此我们实际上需要进行编译时计算,这些计算在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
此函数代表几种情况:
- 如果其中一个递归搜索成功,而另一个
NotFound
,则我们采用成功搜索的路径,并在正确的方向附加转弯。 - 如果两个递归搜索都以
NotFound
终止,那么显然整个搜索都以NotFound
终止。 - 在任何其他情况下,我们都会得到
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
实现? 我们有几个条件:
record
必须实现Generic
以便可以应用泛型机制,因此我们获得了Generic record
。Search
函数应该在record
找到part
(或者在record
的Generic
表示形式中,表示为Rep record
)。 在代码中,这看起来有点不寻常: Search part (Rep record) ~ 'Found path
。 此记录表示限制,即Search part (Rep record)
的结果Search part (Rep record)
应等于'Found path
某些path
的'Found path
”(实际上,这对我们很有趣)。- 我们应该能够将
GHas
与part
一起使用,从最后一步开始,它是record
和path
的通用表示形式,它将变成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) path
是a0, ..., an
恰好在a0, ..., an
之间发生的事实。
愿我们现在拥有美好的旧时光
data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig }
并且我们要输出Has DbConfig
, Has WebServerConfig
和Has CronConfig
。 包含DeriveGeneric
和DeriveAnyClass
并添加正确的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) | ^^^^^^^^^^^^
一切似乎都很好。
总结一下
因此,我们将尝试简要地提出所提出的方法。
假设我们有某种类型的打字机,并且我们想根据一些递归规则自动显示其实例。 然后,我们可以避免歧义(如下所示:如果规则不重要且不适合解决实例的标准机制,则通常表达这些规则):
- 我们以归纳数据类型
T
的形式编码递归规则T
- 我们将在类型(以类型族的形式)上编写一个函数,以初步计算该类型
T
的值v
(或者就Haskell而言,类型T
类型v
其中是我的从属类型),它描述了需要采取的具体步骤顺序。 - 使用此
v
作为Generic
帮助程序的附加参数,可以确定现在与v
值匹配的实例的特定顺序。
好吧,就是这样!
在以下文章中,我们将研究此方法的一些优雅扩展(以及优雅限制)。
哦是的 跟踪我们的概括顺序很有趣。
- 从
Env -> Foo
。 - 不够笼统,请在
Reader Env
monad中进行总结。 - 不够通用,请使用
MonadReader Env m
重写。 - 不够通用,重写
MonadReader rm, HasEnv r
。 - 不够通用,
MonadReader rm, Has Env r
编写MonadReader rm, Has Env r
并添加泛型,以便编译器在那里执行所有操作。 - 现在是规范。