基于类型的不变量的函数编程答案是什么?


9

我知道不变性的概念存在于多种编程范例中。例如,循环不变式与OO,功能和过程编程有关。

但是,在OOP中发现的一种非常有用的类型是特定类型数据的不变性。这就是标题中所说的“基于类型的不变式”。例如,一个Fraction类型可能具有numeratordenominator,并且其gcd始终为1(即,分数为简化形式)是不变的。我只能通过某种类型的封装来保证这一点,而不是随意设置其数据。作为回报,我不必检查它是否减少了,因此我可以简化等式检查等算法。

另一方面,如果我只是声明一个Fraction类型而没有通过封装提供此保证,那么我就不能安全地在该类型上编写任何函数(假定分数减少了),因为将来有人可能会提出并添加一种方法掌握未减少的分数。

通常,缺少这种不变性可能导致:

  • 需要在多个位置检查/确保前提条件的更复杂算法
  • DRY违规,因为这些重复的前提条件表示相同的基础知识(不变性应为真)
  • 必须通过运行时故障而不是编译时保证来强制执行前提条件

所以我的问题是函数编程对这种不变性的回答是什么。是否有实现或多或少相同功能的惯用方式?还是功能编程的某些方面使收益的相关性降低?


许多功能性语言都可以轻松做到这一点……Scala,F#和其他与OOP配合使用的语言,但是Haskell……基本上任何允许您定义类型及其行为的语言都支持这一点。
AK_

@AK_我知道F#可以做到这一点(尽管IIRC需要一些小的跳跃),并且猜测Scala可以作为另一种跨范式语言。Haskell可以做到这一点很有趣-有链接吗?我真正要寻找的是功能惯用的答案,而不是提供功能的特定语言。但是,当然,一旦您开始谈论惯用语言,事情就会变得相当模糊和主观,这就是为什么我将其排除在外了。
Ben Aaronson

对于在编译时无法检查前提条件的情况,惯用的是检入构造函数。考虑一PrimeNumber堂课。对每个操作的素数执行多个冗余检查会太昂贵,但这不是可以在编译时执行的测试。(许多您想对质数执行的运算,例如乘法运算,都不会形成闭包,即结果可能不能保证是质数。(由于我本人并不知道函数编程,所以将其发布为注释。)
rwong

一个看似无关的问题,但是…… 断言或单元测试是否更重要?
rwong

@rwong是的,那里有一些很好的例子。不过,我实际上不是100%清楚您要达到的最终目标。
Ben Aaronson

Answers:


2

某些功能语言(例如OCaml)具有内置的机制来实现抽象数据类型,因此强制执行某些不变式。没有这种机制的语言依赖于用户“不看地毯”来强制不变式。

OCaml中的抽象数据类型

在OCaml中,模块用于构造程序。模块具有实现签名,后者是模块中定义的值和类型的一种摘要,而前者提供了实际的定义。与.c/.hC程序员熟悉的双连画相比,这可以粗略地比较。

例如,我们可以Fraction像这样实现模块:

# module Fraction = struct
  type t = Fraction of int * int
  let rec gcd a b =
    match a mod b with
    | 0 -> b
    | r -> gcd b r

  let make a b =
   if b = 0 then
     invalid_arg "Fraction.make"
   else let d = gcd (abs a) (abs b) in
     Fraction(a/d, b/d)

  let to_string (Fraction(a,b)) =
    Printf.sprintf "Fraction(%d,%d)" a b

  let add (Fraction(a1,b1)) (Fraction(a2,b2)) =
    make (a1*b2 + a2*b1) (b1*b2)

  let mult (Fraction(a1,b1)) (Fraction(a2,b2)) =
    make (a1*a2) (b1*b2)
end;;

module Fraction :
  sig
    type t = Fraction of int * int
    val gcd : int -> int -> int
    val make : int -> int -> t
    val to_string : t -> string
    val add : t -> t -> t
    val mult : t -> t -> t
  end

现在可以像下面这样使用该定义:

# Fraction.add (Fraction.make 8 6) (Fraction.make 14 21);;
- : Fraction.t = Fraction.Fraction (2, 1)

任何人都可以绕过内置的安全网直接产生类型分数的值Fraction.make

# Fraction.Fraction(0,0);;
- : Fraction.t = Fraction.Fraction (0, 0)

为了防止这种情况,可以隐藏如下类型的具体定义Fraction.t

# module AbstractFraction : sig
  type t
  val make : int -> int -> t
  val to_string : t -> string
  val add : t -> t -> t
  val mult : t -> t -> t
end = Fraction;;

module AbstractFraction :
sig
  type t
  val make : int -> int -> t
  val to_string : t -> string
  val add : t -> t -> t
  val mult : t -> t -> t
end

创建an的唯一方法AbstractFraction.t是使用该AbstractFraction.make功能。

Scheme中的抽象数据类型

Scheme语言没有与OCaml相同的抽象数据类型机制。它依靠用户“不看地毯下面”来实现封装。

在Scheme中,习惯上定义谓词,例如fraction?识别值,从而有机会验证输入。以我的经验,主要用法是让用户验证其输入(如果他伪造了一个值),而不是在每个库调用中验证输入。

但是,有几种策略可以强制对返回值进行抽象化,例如返回一个闭包,该闭包在应用时会产生该值,或者返回对该库管理的池中某个值的引用-但我在实践中从未见过。


+1还值得一提的是,并非所有的OO语言都强制执行封装。
Michael Shaw

5

封装不是OOP随附的功能。任何支持适当模块化的语言都有它。

在Haskell中大致执行以下操作:

-- Rational.hs
module Rational (
    -- This is the export list. Functions not in this list aren't visible to importers.
    Rational, -- Exports the data type, but not its constructor.
    ratio,
    numerator,
    denominator
    ) where

data Rational = Rational Int Int

-- This is the function we provide for users to create rationals
ratio :: Int -> Int -> Rational
ratio num den = let (num', den') = reduce num den
                 in Rational num' den'

-- These are the member accessors
numerator :: Rational -> Int
numerator (Rational num _) = num

denominator :: Rational -> Int
denominator (Rational _ den) = den

reduce :: Int -> Int -> (Int, Int)
reduce a b = let g = gcd a b
             in (a `div` g, b `div` g)

现在,要创建一个Rational,您可以使用比率函数,该函数强制执行不变式。因为数据是不可变的,所以您以后不能违反不变式。

但是,这确实要花一些钱:用户不再可能使用与分母和分子使用相同的解构声明。


4

您以相同的方式进行操作:创建一个强制实施约束的构造函数,并同意在创建新值时使用该构造函数。

multiply lhs rhs = ReducedFraction (lhs.num * rhs.num) (lhs.denom * rhs.denom)

但是Karl,在OOP中,您不必同意使用构造函数。真的吗?

class Fraction:
  ...
  Fraction multiply(Fraction lhs, Fraction rhs):
    Fraction result = lhs.clone()
    result.num *= rhs.num
    result.denom *= rhs.denom
    return result

实际上,FP中此类滥用的机会较少。由于不可变性,您必须将构造函数放在最后。我希望人们不要再将封装看作是对不称职的同事的一种保护,或者避免了沟通限制的需要。它不会那样做。它只是限制了您必须检查的地方。好的FP程序员也使用封装。它只是以传达一些首选功能来进行某些修改的形式出现。


嗯,有可能(而且惯用的)用C#编写代码,例如,这不允许您在其中执行任何操作。而且我认为,负责强制执行不变式的单个类与任何人编写的每个函数(在任何使用某种类型必须强制执行相同不变式的地方)之间都存在明显的区别。
Ben Aaronson

@BenAaronson注意“强制”“传播”不变量之间的区别。
rwong 2015年

1
+1。该技术在FP中更加强大,因为不可变的值不会改变。因此,您可以使用类型“一劳永逸”地证明有关它们的事情。对于可变对象而言,这是不可能的,因为对于它们而言,现在的真实性以后可能不再正确。尽您最大的努力防御性地重新检查对象的状态。
2015年

@Doval我没看到。抛开大多数(?)主要的OO语言都可以使变量保持不变。在OO中,我具有:创建一个实例,然后我的函数以可能符合或可能不符合不变式的方式对该实例的值进行突变。在FP中,我具有:创建一个实例,然后我的函数以可能符合或可能不符合不变式的方式创建具有不同值的第二个实例。我看不到不变性如何帮助我更加确信我的不变式适用于所有类型的实例
Ben Aaronson 2015年

2
@BenAaronson不可变性不会帮助您证明您已经正确实现了类型(即,所有操作都保留了某些给定的不变性。)我要说的是,它允许您传播有关值的事实。您将某种条件(例如,该数字是偶数)编码为一种类型(通过在构造函数中进行检查),并且所产生的值证明原始值满足条件。使用可变对象,您可以检查当前状态并将结果保存为布尔值。该布尔值仅在对象不发生突变(条件为假)的情况下才有效。
2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.