即使你不能对它们进行模式匹配,新类型是否也不会产生成本?

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] 如果 TT'newtype[T] 的表示与 [T'] 的表示完全相同。所以,性能没有损失。

但是,我可以看到两个主要警告。

newtypes 和实例

首先,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函数,在某些假设下,根据需要删除或插入newtypes以使类型匹配,甚至inside其他类型,如对于上面的 [Op Int]。此外,它是一个零成本函数。

请注意 coerce 仅在某些条件下有效(编译器会检查它们)。其中之一是 newtype 构造函数必须可见:如果模块不导出 Op :: a -> Op a,则不能将 Op Int 强制转换为 Int,反之亦然。事实上,如果一个模块导出了类型而不是构造函数,那么通过 coerce 无论如何都可以访问构造函数是错误的。这使得“智能构造函数”的习语仍然安全:模块仍然可以通过不透明类型强制执行复杂的不变量。

一个新类型在(完全)参数类型堆栈中有多深并不重要。在运行时,值 v :: Speedw :: 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 构造函数 不是 完全参数化的。