如何有效地添加存在类型的安全货币值?

How do I efficiently add existentially typed safe money values?

提问人:gregorias 提问时间:11/6/2023 更新时间:11/7/2023 访问量:104

问:

我编写了一个玩具库,它使用依赖类型来表示货币,并在类型签名中表示货币:

data Currency = CHF | EUR | PLN | USD
  deriving stock (Bounded, Enum, Eq, Read, Show)

data CurrencyWitness (c :: Currency) where
  CHFWitness :: CurrencyWitness CHF
  EURWitness :: CurrencyWitness EUR
  PLNWitness :: CurrencyWitness PLN
  USDWitness :: CurrencyWitness USD

deriving stock instance Eq (CurrencyWitness c)
deriving stock instance Show (CurrencyWitness c)


data Money (currency :: Currency) representation = Money
  { moneyCurrency :: !(CurrencyWitness currency)
  , moneyAmount :: !representation
  }
  deriving stock (Eq, Show)

add :: (Num r) => Money c r -> Money c r -> Money c r
add (Money witness1 amount1) (Money _ amount2) =
  Money witness1 (amount1 + amount2)

data SomeMoney r where
  SomeMoney :: Money c r -> SomeMoney r

我现在怎么能写出只有当它们使用相同的货币时才能安全地加钱?你可以想象,在用户提供货币参数的情况下,这可能需要,所以我们无法在编译时检查他们的货币。addSomeMoney :: (Num r) => SomeMoney r -> SomeMoney r -> Maybe (SomeMoney r)

我最好的方法是:

addSomeMoney (SomeMoney m@(Money c _)) (SomeMoney m'@(Money c' _)) = case (c, c') of
  (CHFWitness, CHFWitness) -> Just . SomeMoney $ add m m'
  (PLNWitness, PLNWitness) -> Just . SomeMoney $ add m m'
  -- ...
  (_, _) -> Nothing

这种方法的缺点是编写起来很麻烦,重复,并且在我添加新货币时会出错,因为编译器不会警告我这种情况没有得到处理。

addSomeMoney (SomeMoney m@(Money c _)) (SomeMoney m'@(Money c _)) = _

不起作用。GHC 抱怨重复 : .cConlicting definitions of 'c'

Haskell 货币 依赖 存在型

评论

0赞 willeM_ Van Onsem 11/6/2023
我不是真的在关注在做什么?它比它本身“更多”什么?CurrencyWitnessCurrency
3赞 leftaroundabout 11/6/2023
@willeM_VanOnsem Ad-hoc Singleton 类型。

答:

2赞 leftaroundabout 11/6/2023 #1

一种选择是编写一个函数,将单例降级回正常值:

demoteCurrencyWitness :: CurrencyWitness c -> Currency
demoteCurrencyWitness CHFWitness = CHF
...

然后,您可以使用沼泽标准相等性检查和货币遗忘添加:

addSomeMoney (SomeMoney (Money c amount)) (SomeMoney (Money c' amount'))
 | demoteCurrencyWitness c == demoteCurrencyWitness c'
              = Just . SomeMoney . Money c $ amount+amount'
 | otherwise  = Nothing

在这种情况下,被遗忘的子句 for 将给出明显的错误而不是错误的结果。demoteCurrencyWitnessNothing

写这个仍然有点乏味,所以我会用 singletons-th 代替。

评论

0赞 gregorias 11/6/2023
这解决了最紧迫的重复性和脆弱性问题,谢谢。一个缺点是,不幸的是,它依赖于通过避免类型安全函数来“欺骗”类型系统。如果我们用 替换 ,就不会有警告。一个同时防止这种情况的解决方案将是完美的。add==/=
0赞 leftaroundabout 11/6/2023
我不认为有一种好方法可以完全摆脱相等性检查,但你可以封装它:将加法推广到 -like 运算符 上,这样 .liftA2withSameCurrencySomeMoneyaddSomeMoney = withSameCurrency (+)
5赞 chi 11/6/2023 #2

完全“类型安全”的方法是可能的,但我们必须编写一些样板。

我们从 OP 的代码开始,主要是逐字逐句。我们只添加一些扩展,启用警告(这应该在其他地方更合适,但让我们保持简单)和 .import Data.Typeable

(我还没有检查是否可以删除某些扩展程序)

{-# LANGUAGE GADTs, DerivingStrategies, KindSignatures,
    StandaloneDeriving, DataKinds, RankNTypes, 
    TypeApplications, ScopedTypeVariables #-}
{-# OPTIONS -Wall #-}
   
import Data.Typeable

data Currency = CHF | EUR | PLN | USD
  deriving stock (Bounded, Enum, Eq, Read, Show)

data CurrencyWitness (c :: Currency) where
  CHFWitness :: CurrencyWitness 'CHF
  EURWitness :: CurrencyWitness 'EUR
  PLNWitness :: CurrencyWitness 'PLN
  USDWitness :: CurrencyWitness 'USD

deriving stock instance Eq (CurrencyWitness c)
deriving stock instance Show (CurrencyWitness c)

data Money (currency :: Currency) representation = Money
  { moneyCurrency :: !(CurrencyWitness currency)
  , moneyAmount :: !representation
  }
  deriving stock (Eq, Show)

add :: (Num r) => Money c r -> Money c r -> Money c r
add (Money witness1 amount1) (Money _ amount2) =
  Money witness1 (amount1 + amount2)

data SomeMoney r where
  SomeMoney :: Money c r -> SomeMoney r

现在是新部分。

我们首先“证明”任何货币类型都满足约束。这是微不足道的,但需要一些样板。cTypeable

重要的是,添加新货币将触发非详尽的模式匹配警告,因此保持同步相当容易。

withTypeableCurrency :: CurrencyWitness c -> (Typeable c => a) -> a
withTypeableCurrency CHFWitness x = x
withTypeableCurrency EURWitness x = x
withTypeableCurrency PLNWitness x = x
withTypeableCurrency USDWitness x = x

然后,货币添加功能。我们调用它来将两个约束放在范围内。然后我们利用杠杆来检查两种货币类型是否相同。在肯定的情况下,我们得到的不是无聊的布尔值,而是允许编译器识别类型的方便值。剩下的就很容易了。withTypeableCurrencyTypeableeqTTrueRefl

addSomeMoney :: (Num r) => SomeMoney r -> SomeMoney r -> Maybe (SomeMoney r)
addSomeMoney (SomeMoney (m1@(Money (c1 :: CurrencyWitness tc1) _)))
             (SomeMoney (m2@(Money (c2 :: CurrencyWitness tc2) _))) =
  withTypeableCurrency c1 $
  withTypeableCurrency c2 $
  case eqT @tc1 @tc2 of
    Just Refl -> Just . SomeMoney $ add m1 m2
    Nothing   -> Nothing  -- not the same type of currency

这种方法的优点是样板被限制在 中,并且不必在处理 、 等的每个函数中重复。withTypeableCurrencySomeMoneyaddSomeMoneysubtractSomeMoney


更新:一些样板可以利用包和一些模板 Haskell 删除。singletons

{-# LANGUAGE GADTs, DerivingStrategies, KindSignatures,
    StandaloneDeriving, DataKinds, RankNTypes, TypeApplications,
    ScopedTypeVariables, TemplateHaskell, EmptyCase, TypeFamilies,
    UndecidableInstances, TypeSynonymInstances, InstanceSigs #-}

import Data.Singletons.Decide
import Data.Singletons.TH

$(singletons [d|
  data Currency = CHF | EUR | PLN | USD
    deriving (Eq, Read, Show, Bounded)
  |])
-- This automatically creates the equivalent of
--    data SCurrency :: Currency -> Type where
--       SCHF :: SCurrency 'CHF
--       SEUR :: SCurrency 'EUR
--       SPLN :: SCurrency 'PLN
--       SUSD :: SCurrency 'USD
-- and some helper stuff.

-- singletons does not like deriving Enum, so we move it here.
deriving stock instance Enum Currency

-- singletons does not create this for some reason.
deriving stock instance Eq (SCurrency c)

data Money (currency :: Currency) representation = Money
  { moneyCurrency :: !(SCurrency currency)
  , moneyAmount :: !representation
  }
  deriving stock (Eq, Show)

add :: (Num r) => Money c r -> Money c r -> Money c r
add (Money witness1 amount1) (Money _ amount2) =
  Money witness1 (amount1 + amount2)

data SomeMoney r where
  SomeMoney :: Money c r -> SomeMoney r

关键功能更简单,利用.%~

addSomeMoney :: (Num r) => SomeMoney r -> SomeMoney r -> Maybe (SomeMoney r)
addSomeMoney (SomeMoney m1@(Money c1 _))
             (SomeMoney m2@(Money c2 _)) =
  -- Check for singleton equality.
  case c1 %~ c2 of
    Proved    Refl -> Just . SomeMoney $ add m1 m2
    Disproved _    -> Nothing