用于类型函数调用的GHC代码生成
在Haskell中定义一个类型类的实例,你需要提供一个类型类所需的函数字典。 即定义Bounded
的实例,您需要提供minBound
和maxBound
的定义。
为了这个问题的目的,我们把这个字典作为类型实例的vtbl
。 让我知道这是否可怜类比。
我的问题主要集中在我调用类类函数时可以从GHC得到什么样的代码生成。 在这种情况下,我看到三种可能性:
我想了解每种情况何时发生 - 或者是否还有其他可能性。
另外,如果类型类是在单独编译的模块中定义的,而不是“主”编译单元的一部分,那么它有什么关系?
在可运行程序中,似乎Haskell知道程序中所有函数和表达式的类型。 因此,当我调用一个类型类函数时,编译器应该知道vtbl是什么,以及确切地调用哪个实现函数。 我希望编译器至少能够生成对实现函数的直接调用。 这是真的?
(我在这里说“可运行程序”以区别于编译一个不运行的模块。)
就所有好问题而言,答案是“取决于”。 经验法则是任何类型类多态代码都有一个运行时代价。 然而,图书馆作者在消除GHC重写规则的成本方面有很大的灵活性,特别是有一个{-# SPECIALIZE #-}
编译指示,它可以自动创建单形态版本的多态函数并在多态函数可以使用时使用它们推断用于单形态。 (我认为这样做的代价是库和可执行文件的大小。)
你可以用ghc的-ddump-simpl
标志来回答你的问题。 例如,下面是一个简短的Haskell文件:
vDouble :: Double
vDouble = 3
vInt = length [2..5]
main = print (vDouble + realToFrac vInt)
如果没有优化,您可以看到GHC在运行时执行字典查找:
Main.main :: GHC.Types.IO ()
[GblId]
Main.main =
System.IO.print
@ GHC.Types.Double
GHC.Float.$fShowDouble
(GHC.Num.+
@ GHC.Types.Double
GHC.Float.$fNumDouble
(GHC.Types.D# 3.0)
(GHC.Real.realToFrac
@ GHC.Types.Int
@ GHC.Types.Double
GHC.Real.$fRealInt
GHC.Float.$fFractionalDouble
(GHC.List.length
@ GHC.Integer.Type.Integer
(GHC.Enum.enumFromTo
@ GHC.Integer.Type.Integer
GHC.Enum.$fEnumInteger
(__integer 2)
(__integer 5)))))
...相关位是realToFrac @Int @Double
。 另一方面,在-O2
,您可以看到它静态地查找字典,并内联了实现,结果是对int2Double#
:
Main.main2 =
case GHC.List.$wlen @ GHC.Integer.Type.Integer Main.main3 0
of ww_a1Oq { __DEFAULT ->
GHC.Float.$w$sshowSignedFloat
GHC.Float.$fShowDouble_$sshowFloat
GHC.Show.shows26
(GHC.Prim.+## 3.0 (GHC.Prim.int2Double# ww_a1Oq))
(GHC.Types.[] @ GHC.Types.Char)
}
图书馆作者也可以选择将多态函数重写为对单态函数的调用,但不内联单形函数的实现; 这意味着您提出的所有可能性(以及更多)都是可能的。
如果编译器能够在编译时“告诉”你正在使用的实际类型,那么方法查找就会在编译时发生。 否则它会在运行时发生。 如果查找在编译时发生,则方法代码可以内联,具体取决于方法的大小。 (这也适用于常规函数:如果编译器知道你正在调用哪个函数,如果该函数足够小,它将内联它。)
例如,考虑(sum [1 .. 10]) :: Integer
。 在这里,编译器静态地知道该列表是Integer
的列表,因此它可以内联Integer
的+
函数。 另一方面,如果你做类似的事情
foo :: Num x => [x] -> x
foo xs = sum xs - head x
那么当你调用sum
,编译器不知道你使用的是什么类型。 (这取决于给予foo
类型),所以它不能进行任何编译时查找。
另一方面,使用{-# SPECIALIZE #-}
编译指示,您可以执行类似操作
{-# SPECIALIZE foo:: [Int] -> Int #-}
它所做的是告诉编译器编译一个特殊版本的foo
,其中输入是Int
值列表。 这显然意味着对于该版本,编译器可以在编译时进行所有方法查找(并且几乎可以肯定将它们全部内联)。 现在有两个版本的foo
- 一个适用于任何类型,并且运行时查找类型,另一个仅适用于Int
,但[可能]更快。
当你调用foo
函数时,编译器必须决定调用哪个版本。 如果编译器能够在编译时“告诉”你想要的是Int
版本,它就会这样做。 如果它不能“告诉”你将要使用的类型,它将使用较慢的任意类型版本。
请注意,您可以拥有单个函数的多个特化。 例如,你可以做
{-# SPECIALIZE foo :: [Int] -> Int #-}
{-# SPECIALIZE foo :: [Double] -> Double #-}
{-# SPECIALIZE foo :: [Complex Double] -> Complex Double #-}
现在,只要编译器可以告诉您使用这些类型之一,它就会使用该类型的硬编码版本。 但是,如果编译器无法知道您使用的是什么类型,它将永远不会使用专用版本,并且始终是多态的版本。 (这可能意味着您需要专门化调用foo
的函数)。
如果你抓住编译器的核心输出,你可能很清楚它在特定情况下做了什么。 尽管你可能会疯狂地疯狂...
如其他答案所述,其中任何一种情况都可能发生在不同的情况下 对于任何特定的函数调用,唯一确定的方法是查看生成的内核。 也就是说,有些情况下你可以很好地了解会发生什么。
在单形类型中使用类型类方法。
当在编译时完全知道类型的情况下调用类型方法时,GHC将在编译时执行查找。 例如
isFive :: Int -> Bool
isFive i = i == 5
这里编译器知道它需要Int
的Eq字典,所以它会发出代码来静态调用该函数。 这个调用是否内联取决于GHC的常规内联规则,以及INLINE
注是否适用于类方法定义。
公开一个多态函数
如果一个多态函数是从编译模块公开的,那么基本情况就是查找需要在运行时执行。
module Foo (isFiveP) where
isFiveP :: (Eq a, Num a) => a -> Bool
isFiveP i = i == 5
GHC实际上做的是将其转换为形式的函数(或多或少)
isFiveP_ eqDict numDict i = (eq_op eqDict) i (fromIntegral_fn numDict 5)
所以函数查找需要在运行时执行。
无论如何,这是基本情况。 实际情况是,GHC对于跨模块内联可能非常积极。 isFiveP
足够小,它将被内联到呼叫站点。 如果类型可以在调用站点确定,那么字典查找将在编译时执行。 即使一个多态函数没有在调用点直接内联,由于GHC通常的函数转换,字典查找仍然可以在编译时执行,如果代码曾经到了函数(带类字典参数)可以使用的形式应用于静态已知的字典。