Lorentz:用Haskell Newtypes实现正确性

110 阅读9分钟

上一篇文章中,我们介绍了洛伦兹基本语法,它允许人们以如下方式编写代码:

-- | Accepts distance on X and Y axes on stack
--   and pushes square of Euclidean distance back.
distance :: [Integer, Integer] :-> '[Natural]
distance = do
  dup; mul
  dip $ do dup; mul
  add; isNat
  if IsSome then nop
  else push [mt|Something went wrong|]; failWith

Michelson提供了几种基本的基元类型以及将它们分组为乘积(pair)和总和(or)的方法,我们仍然遵循这种行为。正如在关于Lorentz对象的衍生文章中指出的那样,将类型分组为乘积总和的机会与Haskell的数据类型相对应,但还有一项功能仍未使用。

在这篇文章中,我们将讨论Haskellnewtypes,以及为什么它们可以成为像Lorentz这样的智能合约eDSL中的一个强大工具。

关于类型签名语法的说明。

在这篇文章中,为了简单起见,我们将在签名中写[a, b] :-> [c, d] ,而不是a : b : s :-> c : d : s 。读者可能会注意到,这样的方法将不够通用,但Lorentz有一个framed 函数,它使任何给定的指令在堆栈尾部具有多态性,所以这并不是一个大问题。

另外,根据Haskell的规则,少于两个元素的类型的列表必须在前缀上打勾,以避免与普通列表产生歧义。

动机

Newtype是一种类型,它仅仅是另一种已经存在的类型的包装,但可能有其自定义的语义。 Newtype在性能上应该是自由的,并且只影响类型检查阶段,所以它们允许在编译时验证更严格的正确性保证。

我们遇到了两类现实生活中的例子,在这些例子中,新类型是有用的。

区分具有相同表示的不同实体

例如,对于高级代币智能合约来说,允许参与者拥有多个带钱的地址是很常见的。 用户可能想知道这些地址的余额,最终,我们可能需要有三种方法来实现:

-- | Fetch balance of a single address.
getAddressBalance
  :: [Address, Storage] :-> '[Mutez]

-- | Evaluate the total balance of a participant.
getParticipantBalance
  :: [Address, Storage] :-> '[Mutez]

-- | Evaluate balance of the participant owning given address.
getOwnerBalance
  :: [Address, Storage] :-> '[Mutez]

当以提到的形式使用这些方法时,类型对我们一点帮助都没有。有可能错误地将getOwnerBalance 给参与者的地址。

如果我们设法使这样的坏代码不可能被写出来,那就应该改善开发经验和所产生的契约的正确性。

具有不变性的类型

让我们想象一下,业务逻辑假设你对每个地址都有几个标志:它是否被用户锁定,余额变化时是否应该通过电子邮件通知用户,用户的猫是否被授权从这个地址花钱,<插入你自己>。

表示这样一个标志集的一般有效方法是保留一个数字比特掩码。 遵循故障快速原则,你要确保在整个合同的任何阶段都不会产生一个无效的比特掩码。 而且,实际上,可能有很多地方比特掩码会变得无效--合同开始,单独的标志变化(以防一些标志与其他标志相矛盾),以及整体的变化(由用户批量更新)。你想确保在这些地方都没有缺少有效性检查。

在一般的Haskell代码中,这类问题通常是通过声明一个具有明确不变性的新类型,并提供处理这些不变性的构造函数/修改器来解决的。

我们的方法

在Lorentz中,我们声明新类型的方法如下:

newtype MyType = MyType Integer
  deriving newtype IsoValue

让我们提醒一下,IsoValue 描述了Lorentz类型如何被翻译成Michelson,而deriving newtype 子句是Haskell的语法,表示IsoValue 的行为应该从内部类型继承,在我们的例子中是Integer

现在人们可以写了:

putDefaultMyType :: '[] :-> '[MyType]
putDefaultMyType = push (MyType 0)

这很简单。

我们得到的主要好处是,MyType 被视为与Integer 或任何其他新类型包装器完全不同的类型Integer ,所以将一个函数错误地应用于一个错误的参数会引起编译错误。

现在我们希望有专门的方法在newtype和它的内部表示之间进行转换。Lorentz已经定义了forcedCoerce_ ,该方法可以在具有相同的迈克尔逊表示的任何两个类型之间进行转换,但是它非常广泛,需要开发者特别小心才能正确使用:一个错别字或错误的心理模型很可能导致一个错误,所以在业务逻辑层应该避免使用这种方法。

我们希望允许用户通过专门用于newtype封装/解封装的方法来实现他们的具体意图。出于这个原因,我们定义了以下方法:

coerceWrap :: Wrappable a => '[Unwrappable a] :-> '[a]
coerceWrap = forcedCoerce_

coerceUnwrap :: Wrappable a => '[a] :-> '[Unwrappable a]
coerceUnwrap = forcedCoerce_

关于类型签名还有一点要注意。

作为库的一部分,这两个方法当然是以一种更通用的方式定义的

请注意Wrappable a :当我们的新类型没有任何不变量并且可以安全地被包裹和解包时,我们声明它有一个 Wrappable它看起来像这样:

newtype Participant = Participant Address
  deriving stock (Generic)
  deriving anyclass (IsoValue, Wrappable)

authenticate :: '[Participant] :-> '[]
authenticate = do
  coerceUnwrap  -- unwraps Participant to Address
  sender        -- pushes address of the current transaction executor
  assertEq [mt|Method executed not by the expected participant|]

我们认为coerceWrap 始终是安全的:如果一个给定的新类型有不变性,它不应该实例化Wrappable 类型类,而采用另一种我们稍后会考虑的方式。

具有不变式的新类型

现在让我们考虑前面提到的带有比特掩码的例子。 像以前一样,我们声明一个专门的新类型:

-- | Invariant: keeps a 3-bits number.
-- Use the dedicated smart constructor to make a value of this type.
newtype Flags = FlagsUnsafe { unFlags :: Natural }
  deriving newtype IsoValue

它有三个我们想要支持的用例。 第一个是构造一个Haskell值,以后可以在合约发起时提供。 这可以通过各种方式使用Haskell的内置功能安全地完成,由开发者决定。

我们将按如下方式实现:

-- | Constants, building blocks for 'Flags'.
flag1, flag2, flag3 :: Flags
flag1 = FlagsUnsafe 1
flag2 = FlagsUnsafe 2
flag3 = FlagsUnsafe 4

-- | Provides union of two flags sets.
instance Semigroup Flags where
  a <> b = Flags (unFlags a .|. unFlags b)

-- | Provides empty flags set.
instance Monoid Flags where
  mempty = FlagsUnsafe 0

-- Here also getting "mconcat :: [Flags] -> Flags" with
-- semantics of the union operation

instance Bounded Flags where
  minBound = FlagsUnsafe 0
  maxBound = FlagsUnsafe 7

-- | An arbitrary example.
myCustomFlags :: Flags
myCustomFlags = mconcat [flag1, flag2]

-- As it can be seen, constructing an invalid Flag value
-- (with the inner number greater than 7) is not possible without
-- using unsafe methods.

接下来,我们需要确保契约代码在对Flags 进行操作时不会产生一个无效的值。 Michelson有许多多态操作(ADD,NOT,OR ),恰好在Lorentz中,人们必须明确声明这些操作中哪些是允许引入的类型。在我们的例子中,这实际上是很方便的:如果我们只允许对我们的Flags 类型进行逻辑操作,那么在契约执行期间,其不变性将不会被违反:

-- | Flags intersection.
instance ArithOp And Flags Flags where
  type ArithRes And Flags Flags = Flags

-- | Flags union.
instance ArithOp Or Flags Flags where
  type ArithRes Or Flags Flags = Flags

-- Defining e.g. 'Not' operation does not make much sense for our case,
-- so we do not permit this.

如果有些标志会相互矛盾,我们宁愿定义一个特殊的函数,谨慎地添加标志:

-- | Add 'flag1', fail if it is not possible concerning other present flags.
addFlag1 :: '[Flags] :-> '[Flags]
addFlag1 = do
  ... -- check there are no contradicting flags
  push flag1
  uniteFlagsUnsafe

uniteFlagsUnsafe :: [Flags, Flags] :-> '[Flags]
uniteFlagsUnsafe = do
  forcedCoerce_ @Natural; dip (forcedCoerce_ @Natural);
  or
  forcedCoerce_

使用这组强制器似乎会使契约的效率降低,但由于优化器的存在(出于各种原因,任何eDSL都应该有优化器),这些forcedCoerce_ ,不会出现在结果的Michelson代码中。

最后,最后一种情况是接受用户提供的值。 当用户想覆盖一个标志的现有值时,这可能很有用,而我们需要确保他的输入确实有效。 在这里,newtype帮助我们在我们可以处理或存储的经过检查的数据(Flags)和我们接受为输入的未经检查的数据之间划出一条界线(为了简单起见,让它只是Natural ):

-- | When we know for sure that provided value is valid...
toFlagsUnsafe :: '[Natural] :-> '[Flags]
toFlagsUnsafe = forcedCoerce_

-- | ...and when we don't.
toFlags :: '[Natural] :-> '[Flags]
toFlags = do
  dup
  push (maxBound @Flags); forcedCoerce_ @Natural
  if IsLe
  then do push [mt|Invalid flags value|]; pair; failWith @(MText, Natural)
  else toFlagsUnsafe

底线:在Lorentz中,人们可以以非常类似于一般Haskell代码中的方式使用newtypes。一些以forcedCoerce_ 调用形式的wrap/unwrap模板仍然会涉及到,但现在类型系统会帮助你确保所有的数据被正确使用。

常见的问题

如果我可以写测试,我还需要类型吗?

虽然类型并不是要完全取代测试,但是一个新类型的封装器可以消除对整个测试类别的需求,只是因为有问题的代码不能再使用安全的基元来编写。 此外,如果缺少一些检查,类型系统会提示开发者。 这对库的编写者来说是一个特别有用的属性,因为他们的用户也会自动得到一部分安全。

另外(这一点往往没有被考虑到),测试可能会形成一个相当大的代码库,也必须被维护,所以你希望尽可能避免为琐碎的故障案例编写重复的测试。

对于更多的细节,我推荐Ken Fox的文章,解释为什么同时拥有类型和测试是有益的。

形式化验证呢?

与所描述的newtypes方法不同,形式化验证允许你表达数据的任意属性,因此有一个明显更广泛的范围。

然而,在实践中它的使用是相当复杂的。

  1. 你可能需要知道一种特殊的证明助手语言,如Coq来验证你的程序。
  2. 你必须自己写证明。

这样一来,形式化验证对于大型合同来说可能常常是不可行的。

另一方面,newtypes功能可以让你一次只对一块数据进行约束,因此,例如,amount > 0 || storage.x > 0 不变量不能在这里表达;但这个功能是自然嵌入到语言中的,需要不断地编写模板。

总结

在这篇文章中,我们考虑了Haskell的newtypes功能如何被用来区分语义上不同的类型,在类型级表达保证,并确保验证。

所提到的功能已经成功地用于几个生产合同中。 它的实现可以在Morley资源库中找到(一)。

和我们在一起吧!