即使你不能对它们进行模式匹配,新类型是否也不会产生成本?
Do newtypes incur no cost even when you cannot pattern-match on them?
上下文
我知道的大多数 Haskell 教程(例如 LYAH)都将 newtypes 作为一种免费的惯用语介绍,它允许强制执行更多的类型安全。例如,此代码将进行类型检查:
type Speed = Double
type Length = Double
computeTime :: Speed -> Length -> Double
computeTime v l = l / v
但这不会:
newtype Speed = Speed { getSpeed :: Double }
newtype Length = Length { getLength :: Double }
-- wrong!
computeTime :: Speed -> Length -> Double
computeTime v l = l / v
这将:
-- right
computeTime :: Speed -> Length -> Double
computeTime (Speed v) (Length l) = l / v
在此特定示例中,编译器知道 Speed
只是一个 Double
,因此模式匹配没有实际意义,不会生成任何可执行代码。
问题
当新类型作为参数类型的参数出现时,它们仍然是免费的吗?例如,考虑一个新类型列表:
computeTimes :: [Speed] -> Length -> [Double]
computeTimes vs l = map (\v -> getSpeed v / l) vs
我还可以在 lambda 中对速度进行模式匹配:
computeTimes' :: [Speed] -> Length -> [Double]
computeTimes' vs l = map (\(Speed v) -> v / l) vs
无论哪种情况,出于某种原因,我都觉得真正的工作已经完成了!当新类型被埋在嵌套参数数据类型的深树中时,我开始感到更加不舒服,例如Map Speed [Set Speed]
;在这种情况下,可能很难或不可能在新类型上进行模式匹配,并且必须求助于 getSpeed
.
这样的访问器
TL;DR
使用新类型永远不会产生成本,即使当新类型作为另一个参数类型的(可能深埋的)参数出现时?
就其本身而言,newtypes
是免费的。应用它们的构造函数或对它们进行模式匹配的成本为零。
用作其他类型的参数时,例如[T]
如果 T
是 T'
的 newtype
,[T]
的表示与 [T']
的表示完全相同。所以,性能没有损失。
但是,我可以看到两个主要警告。
newtype
s 和实例
首先,newtype
经常用于引入新的 instance
类型 类。显然,当这些是用户定义的时,无法保证它们与原始实例具有相同的成本。例如,当使用
newtype Op a = Op a
instance Ord a => Ord (Op a) where
compare (Op x) (Op y) = compare y x
比较两个 Op Int
比比较 Int
的成本略高,因为需要交换参数。 (我在这里忽略了优化,这可能会在它们触发时免费。)
newtypes
用作类型参数
第二点比较微妙。考虑身份 [Int] -> [Int]
的以下两个实现
id1, id2 :: [Int] -> [Int]
id1 xs = xs
id2 xs = map (\x->x) xs
第一个成本不变。第二个具有线性成本(假设没有优化触发器)。聪明的程序员应该更喜欢第一种实现,写起来也更简单。
假设现在我们在参数类型上引入newtypes
,只有:
id1, id2 :: [Op Int] -> [Int]
id1 xs = xs -- error!
id2 xs = map (\(Op x)->x) xs
由于类型错误,我们不能再使用常量成本实现。线性成本实现仍然有效,并且是唯一的选择。
现在,这很糟糕。 [Op Int]
的输入表示与 [Int]
完全相同。然而,类型系统禁止我们以有效的方式执行标识!
为了解决这个问题,safe coercions 在 Haskell 中引入。
id3 :: [Op Int] -> [Int]
id3 = coerce
神奇的coerce
函数,在某些假设下,根据需要删除或插入newtype
s以使类型匹配,甚至inside其他类型,如对于上面的 [Op Int]
。此外,它是一个零成本函数。
请注意 coerce
仅在某些条件下有效(编译器会检查它们)。其中之一是 newtype
构造函数必须可见:如果模块不导出 Op :: a -> Op a
,则不能将 Op Int
强制转换为 Int
,反之亦然。事实上,如果一个模块导出了类型而不是构造函数,那么通过 coerce
无论如何都可以访问构造函数是错误的。这使得“智能构造函数”的习语仍然安全:模块仍然可以通过不透明类型强制执行复杂的不变量。
一个新类型在(完全)参数类型堆栈中有多深并不重要。在运行时,值 v :: Speed
和 w :: Double
是完全无法区分的——包装被编译器擦除,所以即使 v
实际上也只是一个指向单个 64 位浮点数的指针在记忆中。该指针是否存储在列表或树中或其他任何地方都没有区别。 getSpeed
是一个空操作,不会以任何方式在运行时出现。
那么“完全参数化”是什么意思?问题是,newtypes can 显然会通过类型系统在编译时产生影响。特别是,它们可以指导实例解析,因此调用不同 class 方法的新类型肯定比包装的性能更差(或者,同样容易地,更好!)类型。例如,
class Integral n => Fibonacci n where
fib :: n -> Integer
instance Fibonacci Int where
fib = (fibs !!)
where fibs = [ if i<2 then 1
else fib (i-2) + fib (i-1)
| i<-[0::Int ..] ]
这个实现非常慢,因为它使用惰性列表(并在其中一遍又一遍地执行查找)来进行记忆。另一方面,
import qualified Data.Vector as Arr
-- | A number between 0 and 753
newtype SmallInt = SmallInt { getSmallInt :: Int }
instance Fibonacci SmallInt where
fib = (fibs Arr.!) . getSmallInt
where fibs = Arr.generate 754 $
\i -> if i<2 then 1
else fib (SmallInt $ i-2) + fib (SmallInt $ i-1)
这个fib
要快很多,因为输入被限制在一个小范围内,严格分配全部的结果是可行的在快速 O (1) 查找数组中,不需要 spine-laziness。
这当然再次适用,无论您将数字存储在什么结构中。但是不同的性能只是因为调用了不同的方法实例——在运行时这意味着简单、完全不同的函数。
现在,完全参数 类型的构造函数必须能够存储任何 类型的值。特别是,它不能对包含的数据施加任何 class 限制,因此也不会调用任何 class 方法。因此,如果您只是处理通用 [a]
列表或 Map Int a
映射,则不会发生这种性能差异。但是,当您处理 GADT 时,它可能会发生。在这种情况下,即使是实际的内存布局也可能完全不同,例如
{-# LANGUAGE GADTs #-}
import qualified Data.Vector as Arr
import qualified Data.Vector.Unboxed as UArr
data Array a where
BoxedArray :: Arr.Vector a -> Array a
UnboxArray :: UArr.Unbox a => UArr.Vector a -> Array a
可能允许您比 Speed
值更有效地存储 Double
值,因为前者可以存储在缓存优化的未装箱数组中。这是唯一可能的,因为 UnboxArray
构造函数 不是 完全参数化的。
上下文
我知道的大多数 Haskell 教程(例如 LYAH)都将 newtypes 作为一种免费的惯用语介绍,它允许强制执行更多的类型安全。例如,此代码将进行类型检查:
type Speed = Double
type Length = Double
computeTime :: Speed -> Length -> Double
computeTime v l = l / v
但这不会:
newtype Speed = Speed { getSpeed :: Double }
newtype Length = Length { getLength :: Double }
-- wrong!
computeTime :: Speed -> Length -> Double
computeTime v l = l / v
这将:
-- right
computeTime :: Speed -> Length -> Double
computeTime (Speed v) (Length l) = l / v
在此特定示例中,编译器知道 Speed
只是一个 Double
,因此模式匹配没有实际意义,不会生成任何可执行代码。
问题
当新类型作为参数类型的参数出现时,它们仍然是免费的吗?例如,考虑一个新类型列表:
computeTimes :: [Speed] -> Length -> [Double]
computeTimes vs l = map (\v -> getSpeed v / l) vs
我还可以在 lambda 中对速度进行模式匹配:
computeTimes' :: [Speed] -> Length -> [Double]
computeTimes' vs l = map (\(Speed v) -> v / l) vs
无论哪种情况,出于某种原因,我都觉得真正的工作已经完成了!当新类型被埋在嵌套参数数据类型的深树中时,我开始感到更加不舒服,例如Map Speed [Set Speed]
;在这种情况下,可能很难或不可能在新类型上进行模式匹配,并且必须求助于 getSpeed
.
TL;DR
使用新类型永远不会产生成本,即使当新类型作为另一个参数类型的(可能深埋的)参数出现时?
就其本身而言,newtypes
是免费的。应用它们的构造函数或对它们进行模式匹配的成本为零。
用作其他类型的参数时,例如[T]
如果 T
是 T'
的 newtype
,[T]
的表示与 [T']
的表示完全相同。所以,性能没有损失。
但是,我可以看到两个主要警告。
newtype
s 和实例
首先,newtype
经常用于引入新的 instance
类型 类。显然,当这些是用户定义的时,无法保证它们与原始实例具有相同的成本。例如,当使用
newtype Op a = Op a
instance Ord a => Ord (Op a) where
compare (Op x) (Op y) = compare y x
比较两个 Op Int
比比较 Int
的成本略高,因为需要交换参数。 (我在这里忽略了优化,这可能会在它们触发时免费。)
newtypes
用作类型参数
第二点比较微妙。考虑身份 [Int] -> [Int]
id1, id2 :: [Int] -> [Int]
id1 xs = xs
id2 xs = map (\x->x) xs
第一个成本不变。第二个具有线性成本(假设没有优化触发器)。聪明的程序员应该更喜欢第一种实现,写起来也更简单。
假设现在我们在参数类型上引入newtypes
,只有:
id1, id2 :: [Op Int] -> [Int]
id1 xs = xs -- error!
id2 xs = map (\(Op x)->x) xs
由于类型错误,我们不能再使用常量成本实现。线性成本实现仍然有效,并且是唯一的选择。
现在,这很糟糕。 [Op Int]
的输入表示与 [Int]
完全相同。然而,类型系统禁止我们以有效的方式执行标识!
为了解决这个问题,safe coercions 在 Haskell 中引入。
id3 :: [Op Int] -> [Int]
id3 = coerce
神奇的coerce
函数,在某些假设下,根据需要删除或插入newtype
s以使类型匹配,甚至inside其他类型,如对于上面的 [Op Int]
。此外,它是一个零成本函数。
请注意 coerce
仅在某些条件下有效(编译器会检查它们)。其中之一是 newtype
构造函数必须可见:如果模块不导出 Op :: a -> Op a
,则不能将 Op Int
强制转换为 Int
,反之亦然。事实上,如果一个模块导出了类型而不是构造函数,那么通过 coerce
无论如何都可以访问构造函数是错误的。这使得“智能构造函数”的习语仍然安全:模块仍然可以通过不透明类型强制执行复杂的不变量。
一个新类型在(完全)参数类型堆栈中有多深并不重要。在运行时,值 v :: Speed
和 w :: Double
是完全无法区分的——包装被编译器擦除,所以即使 v
实际上也只是一个指向单个 64 位浮点数的指针在记忆中。该指针是否存储在列表或树中或其他任何地方都没有区别。 getSpeed
是一个空操作,不会以任何方式在运行时出现。
那么“完全参数化”是什么意思?问题是,newtypes can 显然会通过类型系统在编译时产生影响。特别是,它们可以指导实例解析,因此调用不同 class 方法的新类型肯定比包装的性能更差(或者,同样容易地,更好!)类型。例如,
class Integral n => Fibonacci n where
fib :: n -> Integer
instance Fibonacci Int where
fib = (fibs !!)
where fibs = [ if i<2 then 1
else fib (i-2) + fib (i-1)
| i<-[0::Int ..] ]
这个实现非常慢,因为它使用惰性列表(并在其中一遍又一遍地执行查找)来进行记忆。另一方面,
import qualified Data.Vector as Arr
-- | A number between 0 and 753
newtype SmallInt = SmallInt { getSmallInt :: Int }
instance Fibonacci SmallInt where
fib = (fibs Arr.!) . getSmallInt
where fibs = Arr.generate 754 $
\i -> if i<2 then 1
else fib (SmallInt $ i-2) + fib (SmallInt $ i-1)
这个fib
要快很多,因为输入被限制在一个小范围内,严格分配全部的结果是可行的在快速 O (1) 查找数组中,不需要 spine-laziness。
这当然再次适用,无论您将数字存储在什么结构中。但是不同的性能只是因为调用了不同的方法实例——在运行时这意味着简单、完全不同的函数。
现在,完全参数 类型的构造函数必须能够存储任何 类型的值。特别是,它不能对包含的数据施加任何 class 限制,因此也不会调用任何 class 方法。因此,如果您只是处理通用 [a]
列表或 Map Int a
映射,则不会发生这种性能差异。但是,当您处理 GADT 时,它可能会发生。在这种情况下,即使是实际的内存布局也可能完全不同,例如
{-# LANGUAGE GADTs #-}
import qualified Data.Vector as Arr
import qualified Data.Vector.Unboxed as UArr
data Array a where
BoxedArray :: Arr.Vector a -> Array a
UnboxArray :: UArr.Unbox a => UArr.Vector a -> Array a
可能允许您比 Speed
值更有效地存储 Double
值,因为前者可以存储在缓存优化的未装箱数组中。这是唯一可能的,因为 UnboxArray
构造函数 不是 完全参数化的。