【问题标题】:When are the results of default methods in instances cached?什么时候缓存实例中默认方法的结果?
【发布时间】:2022-11-25 09:06:39
【问题描述】:

考虑以下模块:

{-# LANGUAGE GeneralisedNewtypeDeriving #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DefaultSignatures #-}

module Lib where

import Data.List (foldl')

doBigSum :: (Enum a, Num a) => a
doBigSum = foldl' (+) 0 [1..200000000]

f :: (Enum a, Num a) => a -> a
f x = x + doBigSum

class BigSum a where
  bigSum :: a
  default bigSum :: (Enum a, Num a) => a
  bigSum = doBigSum

newtype A = A Integer deriving newtype (Enum, Num, Show)
newtype B = B Integer deriving newtype (Enum, Num, Show)

instance BigSum A where
  bigSum = doBigSum

instance BigSum B

g :: (Num a, BigSum a) => a -> a
g x = x + bigSum

假设我们在这里也使用 GHC。

在这里我要注意一些事情(我相信这是真的,如果我错了请纠正我):

  1. 除非有一些花哨的优化/内联,否则 doBigSum 很有可能不会被缓存,而是为每个引用重新计算,因为 doBigSum 实际上有一个隐藏的参数,它是类型 a 的类型类字典它正在被实例化。
  2. 然而,在实例定义中BigSum AbigSum将被缓存,并且每个后续引用都将使用该值。

    事实上,这就是我所看到的,如果我创建一个像这样的主要功能:

    import Lib
    
    main :: IO ()
    main = do
      print "Start"
      print ((f 1) :: A)
      print ((f 2) :: A)
    

    并且在没有优化的情况下编译(单独的模块在这里很重要),两个打印语句的输出之间显然存在时间差距。

    但如果我这样做:

    import Lib
    
    main :: IO ()
    main = do
      print "Start"
      print ((g 1) :: A)
      print ((g 2) :: A)
    

    然后g 2的结果紧跟在g 1的结果之后打印。显然,BigSum A 的实例定义导致为 bigSum :: A 创建一个单独的常量。

    现在考虑

    import Lib
    
    main :: IO ()
    main = do
      print "Start"
      print ((g 1) :: B)
      print ((g 2) :: B)
    

    请注意,BigSum B 的实例定义并不明确,它取决于默认值。

    现在这里发生了什么?是吗:

    1. bigSum 的一个实现,即默认值,它有一个类型的隐藏参数,很像 doBigSum 做的,所以结果不被缓存或
    2. 对于BigSum 的每个实例都有一个单独的bigSum 实现,它专用于所讨论的类型,因此当为特定类型调用bigSum 时,它只会为该类型计算一次。

      我的测试表明发生的情况是案例 (2),这对我的用例很有用,但我想知道我可以在多大程度上依赖它。

      我的实际用例更像是以下内容:

      data ConversionInfo a = ...
      
      data Conversions a = Conversions { convA :: a -> A, convB :: a -> B, convC :: a -> C } 
      
      f :: ConversionInfo a -> Conversions a
      f = ... -- Lots of work happens here
      
      class SimpleConversion a where
        conversionInfo :: ConversionInfo a
        conversions :: Conversions a
        conversions = f conversionInfo
      
      class Conversions a where
        conversionA :: a -> A
        default conversionA :: SimpleConversion a => a -> A
        conversionA = convA conversions
      
        conversionB :: a -> B
        default conversionB :: SimpleConversion a => a -> B
        conversionB = convB conversions
      
        conversionC :: a -> C
        default conversionC :: SimpleConversion a => a -> C
        conversionC = convC conversions
      

      我想可靠地确定的是,每次我为某些 Xblah 调用 conversionX blah 时,都不会重新计算 f。相反,我希望 f 对每种类型的 SimpleConversion 只运行一次。其他任何事情都会完全增加运行时成本,因为与实际转换相比,f 做了很多工作。

      对此的任何文档/参考将不胜感激。

【问题讨论】:

    标签: haskell ghc


    【解决方案1】:

    简短回答:方法是通过默认类方法还是通过实例定义来定义并不重要。重要的是方法的类型在特定实例定义中是单态的。这意味着一旦解析了实例类型变量(例如bigSum :: A),方法的类型就必须是单态的,并且实例本身也是单态的(例如instance BigSum A,但不是instance BigSum [a])。在这种情况下,可以保证每个实例只会评估一个 bigSum 值。

    长答案:您无疑知道,在编译过程的早期,类被脱糖为字典数据类型,实例被脱糖为这些数据类型的值,约束被转换为显式字典参数。默认方法被简单地分解为多态函数,这些函数将在必要时用于为声明的实例中的非特定方法创建方法字段。换句话说,您的示例或多或少被分解为以下内容:

    -- dictionary-passing versions of `doBigSum` and `f`
    doBigSum :: Enum a -> Num a -> a
    doBigSum dEnum dNum = ...
    
    f :: Enum a -> Num a -> a -> a
    f dEnum dNum x = x + doBigSum dEnum dNum
    
    -- the BigSum class
    data BigSum a = BigSum { bigSum :: a }
    
    -- default class method for bigSum
    dm_bigSum :: BigSum a -> Enum a -> Num a -> a
    dm_bigSum _dBigSum dEnum dNum = doBigSum dEnum dNum
    

    A 的派生字典和实例脱糖为:

    newtype A = A Int
    fEnumA = coerce fEnumInt   -- `Enum A` dictionary
    fNumA = coerce fNumInt     -- `Num A` dictionary
    
    fBigSumA :: BigSum A
    fBigSumA = BigSum { bigSum = bigSumA }
    
    bigSumA :: A
    bigSumA = doBigSum fEnumA fNumA
    

    B 的派生字典和实例脱糖为:

    newtype B = B Int
    fEnumB = coerce fEnumInt
    fNumB = coerce fNumInt
    
    fBigSumB :: BigSum B
    fBigSumB = BigSum { bigSum = bigSumB }
    
    bigSumB :: B
    bigSumB = dm_bigSum fBigSumB fEnumB fNumB
    

    and g desugared into:

    g :: Num a -> BigSum a -> a -> a
    g dNum dBigSum x = (+) dNum x (bigSum dBigSum)
    

    请注意,A(不使用默认方法)和B(确实使用)都有一个通过多态函数定义的bigSum字段(doBigSum直接用于AdoBigSum通过B 的多态函数dm_bigSum)。重要的是,bigSum 字段值——即bigSumAbigSumB——都是命名的单态值。

    在对 f 的调用中,每次调用 f 时都会重新计算表达式 doBigSum dEnum dNum。但是,在对g 的调用中,字段选择表达式bigSum dBigSum 只是从字典中选择单态字段。如果您以 A 类型调用它十几次,它只会返回相同的 bigSumA :: A thunk。

    因此,您可以保证 bigSum 将在本示例的每个实例中被评估一次。

    然而,这种保证依赖于两件事。首先,它依赖于解析实例参数后方法是单态的。如果你有:

    class BigPair a where
      bigPair :: (Num b) => (a,b)
      default bigPair :: (Enum a, Num a, Num b) => (a, b)
      bigPair = (doBigSum, 123456789)
    
    instance BigPair A where
      bigPair = (doBigSum, 123456789)
    instance BigPair B
    
    g :: (Num a, BigPair a, Num b) => a -> (a, b)
    g x = let (a, b) = bigPair in (a+x, b)
    

    您可能会发现像 g 1 :: (A, Int) 这样的表达式每次运行时都会导致重新计算 doBigSum。与g 1 :: (B, Int) 相同。问题是A 的实例字典中的字段属于forall b. Num b => (A, b) 类型,因此它不是单态的。编译器可能从字段表达式中提取 doBigSum 的评估,但您现在依赖于优化。在我的测试中,在 GHCi 下运行或使用 -O0 编译,doBigSum 每次都被重新评估。用-O2编译,只评估了一次。

    其次,它依赖于实例本身是单态的。您可以轻松编写一个多态实例,每次调用它时都会重新计算 bigSum。考虑:

    instance (Enum a, Num a) => BigSum [a] where
      bigSum = [doBigSum]
    

    这脱糖成功能生成一系列实例字典:

    fBigSumList :: Enum a -> Num a -> BigSum [a]
    fBigSumList dEnum dNum = BigSum { bigSum = bigSumList dEnum dNum }
    
    bigSumList :: Enum a -> Num a -> a
    bigSumList dEnum dNum = [doBigSum dEnum dNum]
    

    现在,任何特定类型 a 都不再有任何命名的单态字段值。表达方式:

    bigSum :: [Int]
    

    脱糖到电话中:

    bigSum (fBigSumList fEnumInt fNumInt) 
    = bigSumList fEnumInt fNumInt
    = [doBigSum dEnum dNum]
    

    这可能会导致重新评估doBigSum

    在我对一个简单示例的测试中,每次评估 bigSum :: [Int] 时,GHCi 都会重新评估 doBigSum。然而,即使使用 -O0 编译也只评估了一次,但可能是因为 main 函数中的公共子表达式消除,所以这是一个潜在的非常脆弱的优化。

    在您的 Conversions 示例中,一旦类型变量 a 被解析,字段 Conversions a 应该是单态的,所以我相信您定义的任何单态实例都应该共享 conversions 的单个评估副本。 (一个警告:因为字段是功能,你依赖于f正在做的“工作”被适当地从f返回的函数集合中提取出来。)如果你改变Conversions数据类型的定义,所以它包含一些更高的 - rank 多态性,或者如果你定义多态的SimpleConversion 实例,你可能会失去这个保证。

    您可以通过编译类似以下内容来检查精确的脱糖:

    ghc -O0 -ddump-ds -dsuppress-all -dno-suppress-type-applications 
            -dno-suppress-type-signatures -dsuppress-uniques -fforce-recomp 
            ...
    

    请注意,具有单个方法的类被脱糖为新类型,因此实例字典最终成为单个字段的强制转换,而不是具有多个命名字段的 data 类型。例如,您的代码实际上脱糖成如下内容:

    -- the polymorphic function `doBigSum`
    doBigSum :: forall a. (Enum a, Num a) => a
    doBigSum =  @a $dEnum $dNum -> ...
    
    -- the polymorphic default method
    $dmbigSum :: forall a. (BigSum a, Enum a, Num a) => a
    $dmbigSum =  @a _ $dEnum $dNum -> doBigSum @a $dEnum $dNum
    
    -- the instance dictionary for A
    -- (note: because it's only got one field, it's a cast *of* that field)
    $fBigSumA :: BigSum A
    $fBigSumA = $cbigSum_A `cast` <Co:3>
    
    -- bigSum as defined in the A instance
    $cbigSum_A :: A
    $cbigSum_A = doBigSum @A $fEnumA $fNumA
    
    -- the instance dictionary for B
    $fBigSumB :: BigSum B
    $fBigSumB = $cbigSum_B `cast` <Co:3>
    
    -- bigSum from the default method
    $cbigSum_B :: B
    $cbigSum_B = $dmbigSum @B $fBigSumB $fENumB $fNumB
    

    【讨论】:

    • 谢谢你的长回答!这很有启发性。我要为此奖励你(看起来要等 24 小时才能奖励)
    猜你喜欢
    • 2014-09-14
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-08-17
    • 2023-01-04
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多