GHC是否会消除GADT中Coercible约束的运行时开销?
我定义了如下数据类型:
data T a where T :: Coercible a b => U b -> T a
如果U是Functor,可以实现:
fromT :: T a -> U a fromT (T x) = coerce <$> x
请问GHC会在运行时移除Coercible约束,将其优化为如下形式吗?
data T a where T :: U b -> T a
我觉得存储Coercible字典完全没必要,因为coerce本身不会执行任何操作——它只在类型表示相等时生效。
如果没法免费使用coerce,我就只能手动保证对T的强制转换合法,比如:
type role T representational data T a where T :: U b -> T a makeT :: U a -> T a makeT = T fromT :: T a -> U a fromT (T x) = unsafeCoerce x
而且不暴露T的构造函数。这种方式可行,但我更希望让类型检查器来处理,而不是手动小心操作,同时又不想产生不必要的运行时开销。
补充说明1:动机
我遇到的实际问题是这样的:我有如下类定义:
class C a where getU :: U a
但问题在于U中a的role是nominal而非representational,所以没法这么做:
instance C Alice where getU = ... newtype Bob = Bob Alice deriving newtype C
我大量使用newtype,手动编写实例(哪怕逻辑很简单)不仅繁琐,还可能增加大量间接调用(不确定coerce <$> x能不能被优化掉)。
前面提到的GADT方法只会增加一层固定的间接调用,和newtype的嵌套层数无关。实际里我把getU重命名为getU',然后定义:
getU :: C a => U a getU = fromT . getU'
以此为不写实例的用户维护更简洁的接口。我意识到这个结构类似Coyoneda,之前用它解决过newtype派生的问题。恳请提供低开销的解决方案建议。
补充说明2:过往经验
六年前我遇到过类似问题,当时也是用Coyoneda解决的。
补充说明3:实际场景细节
我实际处理的是Codec类型和HasCodec类。我重新定义HasCodec之后:
data WrappedJSONCodec value where WrappedJSONCodec :: Coercible baseValue value => JSONCodec baseValue -> WrappedJSONCodec value class HasCodec value where codec' :: WrappedJSONCodec value wrapJSONCodec :: JSONCodec value -> WrappedJSONCodec value wrapJSONCodec = WrappedJSONCodec unwrapJSONCodec :: forall value. WrappedJSONCodec value -> JSONCodec value unwrapJSONCodec (WrappedJSONCodec @baseValue baseCodec) = dimapCodec (coerce :: baseValue -> value) (coerce :: value -> baseValue) baseCodec codec :: HasCodec value => JSONCodec value codec = unwrapJSONCodec codec'
现在可以实现newtype派生,只需要在实例定义里调用wrapJSONCodec就行。同时还能省去多层newtype嵌套时的重复实例定义,比如之前要写:
newtype FirstName = FirstName { unFirstName :: Text } instance HasCodec FirstName where codec = dimapCodec FirstName unFirstName codec
我的应用里newtype定义远多于独特的底层类型:虽然有上百种业务类型,但它们的序列化/反序列化只遵循五六种常见模式(比如文本、整数、小数、日期时间等)。所以能实现newtype派生对可读性提升极大,这点代价完全值得。
关于GHC对Coercible约束的优化
首先,GHC确实会在编译时消除Coercible字典的运行时存储。因为Coercible是一个“证据类型”,它的唯一作用是在类型检查阶段证明两个类型可以安全强制转换,运行时没有实际的字典数据需要存储——GHC会把所有Coercible约束的使用直接替换为无操作的强制转换,不会产生额外开销。
你最初的GADT定义:
data T a where T :: Coercible a b => U b -> T a
在运行时的表示和你假设的优化后版本完全一致,不会存储任何额外的字典信息。fromT里的coerce <$> x也会被GHC优化为直接的强制转换(因为Functor实例对于大多数常见类型会被内联,coerce本身是零开销的)。
低开销解决方案建议
结合你的实际场景(HasCodec和newtype派生),你的当前实现已经是最优的低开销方案之一,理由如下:
- 零运行时开销:
WrappedJSONCodec里的Coercible约束不会带来任何运行时负担,unwrapJSONCodec里的coerce会被GHC完全优化掉,dimapCodec的调用如果是针对已知的JSONCodec实例,也会被内联消除。 - 类型安全:完全依赖GHC的类型检查来保证强制转换的合法性,不需要手动使用
unsafeCoerce,避免了潜在的安全隐患。 - 简洁性:对于newtype类型,只需要调用
wrapJSONCodec就能复用底层类型的JSONCodec,省去了手动编写dimapCodec的重复代码,尤其适合大量嵌套newtype的场景。
另外,你提到的Coyoneda结构本质上和当前的WrappedJSONCodec是同一思路——通过延迟类型转换的时机,把nominal role的类型包装成可以通过Coercible转换的形式,从而支持newtype派生。这种模式在Haskell里被广泛用于解决类似的“nominal role类型的newtype派生”问题,是经过实践验证的低开销方案。
额外优化建议
如果想进一步减少样板代码,可以考虑使用DerivingVia扩展来自动生成HasCodec实例。比如:
{-# LANGUAGE DerivingVia #-} newtype FirstName = FirstName Text deriving HasCodec via Text
要实现这个,你需要给Text定义一个辅助的新类型包装器,或者直接让HasCodec支持DerivingVia的实例推导。不过这需要额外的扩展支持,但能进一步减少手动编写wrapJSONCodec的工作量。
内容的提问来源于stack exchange,提问作者Clinton

