如何在 Haskell 中使用额外的类型来获得额外的类型安全

Bre*_*ugh 4 haskell types

我是 Haskell 的新手,并且非常享受。

作为练习,我编写了一个修改日期和时间的程序。特别是,我正在做涉及分钟、秒和微秒的计算。现在我发现,在调试时,我有很多错误,例如,我将分钟添加到秒而不乘以 60。

为了将调试从运行时转移到编译时,我想到我可以使用“类型同义词加多态函数”来做这样的事情:

module Main where
type SecX = Integer
toMin :: SecX -> MinX
toMin m = div m 60
type MinX = Integer
toSec :: MinX -> SecX
toSec = (60 *)
main :: IO ()
main = do
  let x = 20 :: MinX
  let y = 20 :: SecX
  let z = x + y       -- should not compile
  print [x,y,z]
Run Code Online (Sandbox Code Playgroud)

但这种方法给我带来了两个问题:

  1. 标记为“不应该编译”的行实际上确实可以编译,然后继续添加 20 分钟到 20 秒以给出 40 个东西
  2. 当我为微秒添加其他类型的 MuSecX 时,我无法编译 toMin 和 toSec 的其他实例:
    type MuSecX = Integer  
    toSec :: MuSecX -> SecX  
    toSec m = div m 1000000  
    toMin :: MuSecX -> MinX  
    toMin m = div m 60000000  
Run Code Online (Sandbox Code Playgroud)

我显然在这里走错了路。我确定我不是第一个尝试做这样的事情的人,所以任何人都可以提供帮助,最好是“Canonical Haskell Way”?

Fyo*_*kin 9

类型同义词不会保护您免受混合类型的影响,这不是它们的用途。它们实际上只是相同类型的不同名称。它们用于方便和/或文档。但是SecXInteger仍然是完全相同的类型。

为了创建一个全新的类型,请使用newtype

newtype SecX = SecX Integer
Run Code Online (Sandbox Code Playgroud)

如您所见,该类型现在有一个构造函数,可用于构造该类型的新值,以及Integer通过模式匹配从中获取值:

let x = SecX 20 
let (SecX a) = x  -- here, a == 20
Run Code Online (Sandbox Code Playgroud)

类似于MinX

newtype MinX = MinX Integer
Run Code Online (Sandbox Code Playgroud)

转换函数如下所示:

toMin :: SecX -> MinX
toMin (SecX m) = MinX $ div m 60

toSec :: MinX -> SecX
toSec (MinX m) = SecX $ 60 * m
Run Code Online (Sandbox Code Playgroud)

现在这条线确实不会编译

let x = MinX 20
let y = SecX 20 
let z = x + y       -- does not compile
Run Code Online (Sandbox Code Playgroud)

可是等等!这也不再编译:

let sec1 = SecX 20
let sec2 = SecX 20 
let sec3 = sec1 + sec2       -- does not compile either
Run Code Online (Sandbox Code Playgroud)

这是怎么回事?嗯,sec1并且sec2不再只是Integers(这是练习的重点),因此(+)没有为它们定义函数。

但是你可以定义它:函数(+)来自类型 classNum,所以为了SecX支持这个函数,还SecX需要有一个实例Num

instance Num SecX where
    (SecX a) + (SecX b) = SecX $ a + b
    (SecX a) * (SecX b) = SecX $ a * b
    abs (SecX a) = ...
    signum (SecX a) = ...
    fromInteger i = ...
    negate (SecX a) = ...
Run Code Online (Sandbox Code Playgroud)

哇,要实施的东西太多了!另外,乘以秒是什么意思?这有点尴尬,不是吗?嗯,这是因为该类Num实际上是针对numbers 的。预计它的实例真的像数字一样。几秒钟就没有意义了,因为尽管您可以添加它们,但其他操作并没有多大意义。

几秒钟实现的更好的事情是Semigroup(或者甚至是Monoid)。Semigroup 有一个 operation <>,其语义是“将这些东西中的两个粘合在一起并得到另一个相同类型的东西作为回报”,这在几秒钟内效果很好:

instance Semigroup SecX where
    (SecX a) <> (SecX b) = SecX $ a + b
Run Code Online (Sandbox Code Playgroud)

现在这将编译:

let sec1 = SecX 20
let sec2 = SecX 20 
let sec3 = sec1 <> sec2       -- compiles now, and sec3 == SecX 40
Run Code Online (Sandbox Code Playgroud)

同样对于分钟:

instance Semigroup MinX where
    (MinX a) <> (MinX b) = MinX $ a + b
Run Code Online (Sandbox Code Playgroud)

可是等等!我们还是有麻烦!现在print [x, y, z]不再编译了。

好吧,它无法编译的第一个原因是列表[x, y, z]现在包含不同类型的元素,这是不可能发生的。不过好吧,既然只是为了测试,我们可以做print x然后print y,不管。

但这仍然无法编译,因为该函数print要求其参数具有Show的实例- 这是函数所在的位置show,用于将值转换为字符串以进行打印。

当然,我们可以为我们的类型实现它:

class Show SecX where
    show (SecX a) = show a <> " seconds"

class Show MinX where
    show (MinX a) = show a <> " minutes"
Run Code Online (Sandbox Code Playgroud)

或者,我们可以让编译器自动为我们派生实例:

newtype SecX = SecX Integer deriving Show
newtype MinX = MinX Integer deriving Show
Run Code Online (Sandbox Code Playgroud)

但在这种情况下show (SecX 42) == "SecX 42"(或者可能只是"42"取决于启用的扩展),而我上面的手动实现show (SecX 42) == "42 seconds". 您的来电。


呼!现在我们终于可以继续讨论第二个问题:转换函数。

通常的“基本”方法是为不同的函数使用不同的名称:

minToSec :: MinX -> SecX
secToMin :: SecX -> MinX
minToMusec :: MinX -> MuSecX
secToMusec :: SecX -> MuSecX
... and so on
Run Code Online (Sandbox Code Playgroud)

但是如果你真的坚持为函数保留相同的名称,同时让它们使用不同的参数类型,那也是可能的。更一般地说,这称为“重载”,在 Haskell 中,创建重载函数的机制是我们的老朋友类型类。看上面:我们已经(<>)为不同类型定义了函数。我们可以为此创建自己的类型类:

class TimeConversions a where
    toSec :: a -> SecX
    toMin :: a -> MinX
    toMuSec :: a -> MuSecX
Run Code Online (Sandbox Code Playgroud)

然后添加它的实现:

instance TimeConversions SecX where
    toSec = id
    toMin (SecX a) = MinX $ a `div` 60
    toMuSec (SecX a) = MuSecX $ a * 1000000
Run Code Online (Sandbox Code Playgroud)

分钟和微秒也是如此。

用法:

main = do
    let x = SecX 20
    let y = SecX 30
    let a = MinX 5
    let z = x <> y
    -- let u = x <> a  -- doesn't compile
    let v = x <> toSec a

    print [x, y, v]   -- ["20 seconds", "30 seconds", "320 seconds"]
    print a           -- "5 minutes"
    print (toMin x)   -- "0 minutes"
    print (toSec a)   -- "300 seconds"
Run Code Online (Sandbox Code Playgroud)

最后:不要使用Integer,使用IntInteger是任意精度,这意味着它也更慢。Int是 32 位或 64 位值(取决于平台),我认为这应该足以满足您的目的。

但是对于真正的实现,我实际上首先建议使用浮点数(例如Double)。这将使转换完全可逆和无损。用整数,toMin (SecX 20) == MinX 0-我们只是失去了一些信息。