在Haskell类实例中缓存高成本计算结果的实现疑问
expensiveFunction在类实例中重复计算 原始代码如下:
class C a where f :: a -> Text newtype T a = T a expensiveFunction :: (Bounded a, Enum a, ToText a) => Map a Text expensiveFunction = _ cheapFunction :: (Bounded a, Enum a, ToText a) => T a -> Map a Text -> Text cheapFunction = _ instance (Bounded a, Enum a, ToText a) => C (T a) where f x = cheapFunction x expensiveFunction
我希望通过deriving via语法实现U的C实例:
newtype U = U Int deriving C via (T Int)
但发现每次调用f时,expensiveFunction都会被重新计算,导致性能问题。
我尝试修改T a的C实例定义为:
instance (Bounded a, Enum a, ToText a) => C (T a) where f = flip cheapFunction expensiveFunction
期望f作为单参函数只被计算一次,从而让expensiveFunction仅执行一次。但不确定这种方式是否可靠:猜测多态实例下可能无效,但单态实例(比如T Int)或许可行。不过f是类中的实例函数而非独立值,可能每次调用仍会重新计算。
请问这种写法是否可靠?有没有更好的实现方法?希望尽量保留简洁的deriving via语法。
你的写法是否可靠?
对于单态实例(比如T Int),这种写法是可靠的。GHC这类Haskell编译器会对单态值做共享优化——当你把f定义为flip cheapFunction expensiveFunction时,针对具体的a类型(比如Int),expensiveFunction @Int会被计算一次并缓存,之后每次调用f都会复用这个缓存好的Map。
但对于多态的T a,情况不同:因为expensiveFunction是多态的,编译器无法在实例层面缓存它的值,每次调用f @(T a)(如果a仍是多态的)时,expensiveFunction可能会被重新计算。不过你的场景中使用的是单态的T Int,所以这个问题不会出现。
更好的实现方法
如果想确保无论单态还是多态场景下,expensiveFunction都只计算一次,同时保留deriving via语法,可以参考以下几种方式:
1. 全局单态值缓存
为每个需要的类型定义全局的单态expensiveFunction,然后在对应T a的实例中使用它:
-- 全局单态值,程序启动时仅计算一次 expensiveFunctionInt :: Map Int Text expensiveFunctionInt = expensiveFunction @Int instance C (T Int) where f = flip cheapFunction expensiveFunctionInt
此时U的deriving via可以直接复用T Int,因为T Int的f会复用全局缓存好的expensiveFunctionInt。
2. 编译期预计算(进阶)
如果expensiveFunction的结果可以在编译期确定(比如基于Bounded和Enum的枚举值生成Map),可以用TemplateHaskell提前生成对应类型的Map值,彻底避免运行时计算:
import Language.Haskell.TH -- 编译期生成指定类型的expensiveFunction genExpensiveFunction :: Name -> Q Exp genExpensiveFunction tyName = do -- 这里实现编译期遍历枚举值、生成Map的逻辑 -- 示例逻辑,实际需根据ToText实现调整 [t| Map $(conT tyName) Text |] >>= \ty -> do let vals = [minBound..maxBound] :: [Int] -- 替换为动态获取的类型枚举值 mapExp <- listToMap vals -- 自定义函数,将枚举值列表转为Map的TH表达式 return mapExp -- 为Int生成编译期计算好的Map expensiveFunctionInt :: Map Int Text expensiveFunctionInt = $(genExpensiveFunction ''Int) instance C (T Int) where f = flip cheapFunction expensiveFunctionInt
这种方式同样可以配合deriving via使用,无需修改U的定义。
3. 关联值缓存(需修改类结构)
如果允许修改原有的C类,可以添加关联值存储缓存的Map,确保每个实例只计算一次:
-- 定义类型族,获取T a的底层类型 type family UnderlyingType a where UnderlyingType (T a) = a class C a where f :: a -> Text cachedMap :: Map (UnderlyingType a) Text instance (Bounded a, Enum a, ToText a) => C (T a) where cachedMap = expensiveFunction @a f (T x) = cheapFunction (T x) cachedMap
这种方式能统一处理单态和多态场景,但需要修改原有类的结构,适合可以调整类定义的场景。
总结
- 你当前的写法对于单态实例(如
T Int)完全可靠,GHC会自动缓存expensiveFunction @Int的结果。 - 如果需要更通用的缓存方案,全局单态值的方式最简洁,既能保留
deriving via的语法优势,又能确保expensiveFunction仅计算一次。
内容的提问来源于stack exchange,提问作者Clinton

