Clojure中表示和类型(Either a b)的惯用方式

26

编辑后。我的问题是:在静态类型语言中,通常使用哪些惯用的Clojure构造代替总和类型?目前的共识是:如果行为可以统一,则使用协议,否则使用标记对/映射,将必要的断言放在前置条件和后置条件中。

Clojure提供了许多表达乘积类型的方法:向量、映射、记录等等,但是如何表示总和类型,也称为标记联合和变体记录?类似于Haskell中的Either a b或Scala中的Either[+A, +B]

我首先想到的是带有特殊标记的映射:{:tag :left :value a},但是接下来所有的代码都将被污染,并且需要在(:tag value)上添加条件语句并处理特殊情况... 我想要确保的是:tag始终存在,并且它只能采用指定值之一,相应的值始终具有相同类型/行为并且不能为nil,并且有一种简单的方法可以查看我在代码中处理了所有情况。

我可以考虑类似于defrecord的宏,但是针对求和类型:

; it creates a special record type and some helper functions
(defvariant Either
   left Foo
   right :bar)
; user.Either

(def x (left (Foo. "foo")))   ;; factory functions for every variant
; #user.Either{:variant :left :value #user.Foo{:name "foo"}}
(def y (right (Foo. "bar")))  ;; factory functions check types
; SomeException...
(def y (right ^{:type :bar} ()))
; #user.Either{:variant :right :value ()}

(variants x) ;; list of all possible options is intrinsic to the value
; [:left :right]

这样的东西已经存在了吗?(回答:没有)。


1
在LispCast上有一个很好的答案:http://www.lispcast.com/idiomatic-way-to-represent-either - sastanin
7个回答

25
如何表示总和类型(也称为标记联合和变体记录)?例如,在Haskell中的Either a b或在Scala中的Either[+A, +B]Either有两个用途:返回两种类型中的一种值或返回具有不同语义的相同类型的两个值,基于标记。
第一个用途仅在使用静态类型系统时重要。由于Haskell类型系统的限制,Either基本上是可能的最小解决方案。对于动态类型系统,您可以返回任何想要的类型的值。Either是不必要的。
第二个用途非常重要,但可以通过两种或更多方式轻松地实现:
1. {:tag :left :value 123} {:tag :right :value "hello"} 2. {:left 123} {:right "hello"} 我希望确保:
:tag始终存在
它只能采用指定的一些值
对应值始终具有相同的类型/行为且不能为nil
有一种简单的方法可以查看我在代码中处理了所有情况。
如果您想在静态环境下确保这一点,则Clojure可能不是您的选择。原因很简单:表达式直到运行时才具有类型-直到返回值。
宏不起作用的原因是,在宏展开时,您没有运行时值-因此没有运行时类型。您有编译时构造,例如符号、原子、表达式等。您可以使用eval对它们进行评估,但出于多种原因,使用eval被认为是一种不良实践。
但是,我们可以在运行时做得很好。
我的策略是将通常静态的所有内容(在Haskell中)转换为运行时。让我们写一些代码。
;; let us define a union "type" (static type to runtime value)
(def either-string-number {:left java.lang.String :right java.lang.Number})

;; a constructor for a given type
(defn mk-value-of-union [union-type tag value]  
  (assert (union-type tag)) ; tag is valid  
  (assert (instance? (union-type tag) value)) ; value is of correct type  
  (assert value)  
  {:tag tag :value value :union-type union-type}) 

;; "conditional" to ensure that all the cases are handled  
;; take a value and a map of tags to functions of one argument
;; if calls the function mapped to the appropriate tag
(defn union-case-fn [union-value tag-fn]
  ;; assert that we handle all cases
  (assert (= (set (keys tag-fn))
             (set (keys (:union-type union-value)))))
  ((tag-fn (:tag union-value)) (:value union-value)))

;; extra points for wrapping this in a macro

;; example
(def j (mk-value-of-union either-string-number :right 2))

(union-case-fn j {:left #(println "left: " %) :right #(println "right: " %)})
=> right: 2

(union-case-fn j {:left #(println "left: " %)})
=> AssertionError Assert failed: (= (set (keys tag-fn)) (set (keys (:union-type union-value))))

这段代码使用了以下习惯用语的Clojure构造:
- 数据驱动编程:创建代表“类型”的数据结构。该值是不可变和一等公民,您可以使用整个语言来实现其逻辑。我认为Haskell无法做到这一点:在运行时操纵类型。 - 使用映射表示值。 - 高阶编程:向另一个函数传递包含函数的映射。
如果您正在使用Either进行多态,则可以选择使用协议。否则,如果您对标签感兴趣,则类似{:tag :left :value 123}的形式最常见。您经常会看到像这样的东西:
;; let's say we have a function that may generate an error or succeed
(defn somefunction []
  ...
  (if (some error condition)
    {:status :error :message "Really bad error occurred."}
    {:status :success :result [1 2 3]}))

;; then you can check the status
(let [r (somefunction)]
  (case (:status r)
    :error
    (println "Error: " (:message r))
    :success
    (do-something-else (:result r))
    ;; default
    (println "Don't know what to do!")))

谢谢。它涵盖了通常由总和类型提供的功能。问题是,它是否符合Clojure的惯用法? - sastanin
为了使联合类型更符合Clojure的风格,您可以将其定义为{:left string? :right number?}或使用Clojure Spec。这样它就可以在Clojure和ClojureScript中使用,并且类型可以更加灵活或具体化。 - Eva

6
一般来说,动态类型语言中的总和类型表示为:
标记对(例如带有表示构造函数的标记的产品类型)
在运行时针对标记进行案例分析以执行分派
在静态类型语言中,大多数值都是由类型区分的,这意味着您不需要运行时标记分析即可知道您是否拥有Either或Maybe -- 因此,您只需查看标记即可知道它是左侧还是右侧。
在动态类型设置中,您必须首先进行运行时类型分析(以查看您拥有哪种类型的值),然后进行构造函数的情况分析(以查看您拥有哪种风味的值)。
一种方法是为每种类型的每个构造函数分配唯一的标记。
在某种程度上,您可以将动态类型视为将所有值放入单个总和类型中,并将所有类型分析推迟到运行时测试。
-----------------------------------
引用:
我想确保的是:tag始终存在,它只能取指定的一个值,相应的值始终具有相同类型/行为且不能为nil,并且有一种简单的方法可以查看我在代码中处理了所有情况。
顺便说一下,这基本上是静态类型系统所做的事情的描述。

6

使用带有标记的元素作为向量的第一个元素,然后使用core.match来解构标记数据。因此对于上面的例子,“either”数据将被编码为:

[:left 123]
[:right "hello"]

要进行解构,您需要参考core.match并使用以下代码:
(match either
  [:left num-val] (do-something-to-num num-val)
  [:right str-val] (do-something-to-str str-val))

这个回答比其他回答更为简洁。

这个YouTube演讲提供了更详细的解释,说明为什么使用向量来编码变量比使用映射更可取。我的总结是,使用映射来编码变量存在问题,因为你必须记住该映射是一个“标记映射”,而不是一个常规映射。要正确使用“标记映射”,您必须始终进行两个阶段的查找:首先是标记,然后是基于标记的数据。如果您在映射编码的变量中忘记查找标记或者对标记或数据的键查找错误,您将得到一个难以跟踪的空指针异常。

视频还涵盖了以下向量编码变量的方面:

  • 捕获非法标记。
  • 如有需要,使用Typed Clojure进行静态检查。
  • 将这些数据存储在Datomic中。

5
这种方法在某些语言中表现得很好的原因是你会根据结果进行分派(通常是按类型) - 也就是说,你使用结果的某个属性(通常是类型)来决定下一步该做什么。
因此,您需要查看Clojure中如何进行分派。
  1. nil特殊情况 - nil值在各个地方都有特殊处理,并且可以用作"Maybe"的"None"部分。例如,if-let非常有用。

  2. 模式匹配 - 基本的Clojure并没有太多支持,除了解构序列之外,但有各种库可以实现。请参见Clojure replacement for ADTs and Pattern Matching? [更新: 在评论中,mnicky说这已经过时了,你应该使用core.match]

  3. 通过OO按类型 - 方法是按类型选择的。因此,您可以返回父类的不同子类并调用重载以执行所需操作的不同方法。如果您来自函数背景,那么这会感觉非常奇怪/笨拙,但这是一种选择。

  4. 手动添加标签 - 最后,您可以使用casecond来进行显式标记。更有用的是,您可以将它们包装在某种按照您想要的方式工作的宏中。


当我考虑手动实现变体记录时,这个可能的调度策略列表非常有趣。按类型分发似乎最有趣,因为它也适用于Java对象,但可以通过使用“:type”元数据来重写(几乎)所有其他情况。 - sastanin
1
那个关于模式匹配的SO问题已经过时了。请查看core.match... - mnicky

5
作为一种动态类型语言,类型在Clojure中的重要性相对于Haskell/Scala而言要低一些。你不需要显式地定义它们——例如,你已经可以将类型A或类型B的值存储在变量中。
所以这真的取决于你想用这些和类型做什么。很可能你真正感兴趣的是基于类型的多态行为,在这种情况下,定义一个协议和两种不同的记录类型可能是有意义的,这两者结合起来给出了和类型的多态行为:
(defprotocol Fooable
  (foo [x]))

(defrecord AType [avalue]
  Fooable 
    (foo [x]
      (println (str "A value: " (:avalue x)))))

(defrecord BType [bvalue]
  Fooable 
    (foo [x]
      (println (str "B value: " (:bvalue x)))))

(foo (AType. "AAAAAA"))

=> A value: AAAAAA

我认为这种方法可以几乎满足您对和总和类型相关的所有需求。

此方法的其他优点:

  • 在Clojure中,记录和协议非常惯用
  • 性能优异(因为协议分派得到了大力优化)
  • 您可以通过extend-protocol为您的协议添加对空值的处理

谢谢。当值具有可统一行为时,这很有帮助,但当行为不同时(比如值是“错误消息”或Double),这并没有帮助。在我的工作中,我可以使用协议来解决问题。 - sastanin
@sastanin-这种方法对于值完全不同类型的情况会很有效 - 您可以单独扩展协议以处理java.lang.String和java.lang.Double。唯一无法运行的情况是您需要基于其他内容进行分派(但是,您总是可以像上面的示例中那样包装记录类型)。 - mikera

4
没有完成像typed clojure这样惊人的东西,我认为你不能避免断言的运行时检查。
Clojure提供了一个不太知名的特性,可以确实有助于运行时检查,那就是预条件和后置条件的实现(请参见http://clojure.org/special_forms和fogus的一篇博客文章)。我认为,您甚至可以使用单个高阶包装函数来在相关代码上检查所有断言的预条件和后置条件。这很好地避免了运行时检查“污染问题”。

1
几年过去了:Typed Clojure现在使这变得简单。 - Jim Downing

3

目前在Clojure中没有这样的东西。虽然你可以实现它,但是我认为这种类型似乎更适合静态类型语言,并且在像Clojure这样的动态环境中不会给你带来太多好处。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接