哈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并添加泛型,以便编译器在那里执行所有操作。 - 现在是规范。