【问题标题】:Idiomatic way to represent sum type (Either a b) in Clojure在 Clojure 中表示 sum 类型(a b)的惯用方式
【发布时间】:2012-06-12 10:35:51
【问题描述】:

已编辑。我现在的问题是:在静态类型语言中,通常使用什么惯用的 Clojure 构造而不是 sum 类型?到目前为止的共识:如果行为可以统一则使用协议,否则使用标记对/映射,在前置和后置条件中放置必要的断言。

Clojure 提供了多种表示产品类型的方法:向量、地图、记录...,但是您如何表示sum types,也称为标记联合和变体记录?类似于 Haskell 中的 Either a b 或 Scala 中的 Either[+A, +B]

我首先想到的是一张带有特殊标签的地图:{:tag :left :value a},但随后所有代码都将被(:tag value) 上的条件污染,如果不存在则处理特殊情况......我要确保的是,:tag 始终存在,并且它只能采用指定值之一,并且对应的值始终具有相同的类型/行为并且不能是nil,并且有一个很容易看出我处理了代码中的所有情况。

我可以在defrecord 的行中想到一个宏,但是对于 sum 类型:

; 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]

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

【问题讨论】:

标签: types clojure discriminated-union


【解决方案1】:

不,到目前为止,clojure 中还没有这样的东西。尽管您可以实现它,但 IMO 这种类型似乎更适合静态类型语言,并且不会在 clojure 等动态环境中为您带来太多好处。

【讨论】:

    【解决方案2】:

    这在某些语言中运行良好的原因是您对结果进行调度(通常按类型) - 即您使用结果的某些属性(通常是类型)来决定下一步做什么。

    所以你需要看看在 clojure 中调度是如何发生的。

    1. nil 特殊情况 - nil 值在很多地方都是特殊情况,可以用作“Maybe”的“None”部分。例如,if-let 非常有用。

    2. 模式匹配 - 除了解构序列之外,基本 clojure 对此没有太多支持,但有各种库可以支持。请参阅Clojure replacement for ADTs and Pattern Matching? [update:在 cmets mnicky 中说这已过时,您应该使用 core.match]

    3. 按类型使用 OO - 按类型选择方法。因此您可以返回父类的不同子类并调用重载的方法来执行您想要的不同操作。如果你来自一个感觉很奇怪/笨拙的功能背景,但这是一个选择。

    4. 手动标记 - 最后,您可以使用带有显式标记的 casecond。更有用的是,您可以将它们包装在某种按您希望的方式工作的宏中。

    【讨论】:

    • 当我考虑手动实现变体记录时,这个可能的调度策略列表非常有趣。按类型分派似乎最有趣,因为它也适用于 Java 对象,但可以用 :type 元覆盖(几乎)其他所有内容。
    • 关于模式匹配的 SO 问题已经过时了。见core.match...
    【解决方案3】:

    一般来说,动态类型语言中的 sum 类型表示为:

    • 标记对(例如,带有代表构造函数的标记的产品类型)
    • 对标签在运行时做dispatch的案例分析

    在静态类型语言中,大多数值都是通过类型来区分的——这意味着您无需进行运行时标记分析即可知道您是否拥有EitherMaybe——因此您只需查看标记知道它是Left 还是Right

    在动态类型设置中,你必须先做运行时类型分析(看看你有什么类型的值),然后是构造函数的案例分析(看看你有什么风格的值)。

    一种方法是为每种类型的每个构造函数分配一个唯一标记。

    在某种程度上,您可以将动态类型视为将所有值放入单个求和类型,将所有类型分析推迟到运行时测试。


    我想确保的是, :tag 始终存在,并且它只能采用指定值之一,并且对应的值始终具有相同的类型/行为并且不能为 nil,并且存在很容易看出我处理了代码中的所有情况。

    顺便说一句,这几乎是对静态类型系统将做什么的描述。

    【讨论】:

      【解决方案4】:

      你如何表示总和类型,也称为标记联合和 变异记录?类似于 Haskell 中的 Either a bEither[+A, +B] 在 Scala 中。

      Either 有两个用途:返回两种类型之一的值或 返回两个应该具有不同类型的相同类型的值 基于标签的语义。

      第一次使用仅在使用静态类型系统时才重要。 Either 基本上是考虑到的最小解决方案 Haskell 类型系统的约束。使用动态类型系统, 你可以返回你想要的任何类型的值。不需要Either

      第二个用途很重要,但可以很简单地完成 以两种(或更多)方式:

      1. {:tag :left :value 123} {:tag :right :value "hello"}
      2. {:left 123} {:right "hello"}

      我想确保的是 :tag 始终存在,并且它可以 只取指定值之一,对应的值为 始终具有相同的类型/行为,并且不能为零,并且有 很容易看出我处理了代码中的所有情况。

      如果您想以静态方式确保这一点,Clojure 可能不是 你的语言。原因很简单:表达式没有类型 直到运行时——直到它们返回一个值。

      宏不起作用的原因是在宏扩展时,您 没有运行时值——因此没有运行时类型。你有 编译时构造,如符号、原子、s 表达式等。你 可以eval 他们,但使用eval 被认为是不好的做法 原因有很多。

      但是,我们可以在运行时做得很好。

      • 我想确保的是,:tag 始终存在,
      • 并且只能取指定值之一
      • 并且相应的值始终具有相同的类型/行为
      • 并且不能为零
      • 还有一种简单的方法可以看出我处理了代码中的所有情况。

      我的策略是将所有通常是静态的(在 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 无法做到的事情:在运行时操作类型。
      • 使用地图表示值。
      • 高阶编程:将 fns 的映射传递给另一个函数。

      如果您使用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!")))
      

      【讨论】:

      • 谢谢。它涵盖了 sum 类型通常提供的功能。问题是,它是 惯用的 Clojure 吗?
      【解决方案5】:

      作为一种动态类型语言,与在 Haskell / Scala 中相比,Clojure 中的类型通常不太相关/重要。您实际上并不需要显式定义它们 - 例如,您已经可以将 A 类型或 B 类型的值存储在变量中。

      所以这真的取决于你想用这些 sum 类型做什么。您可能真的对基于类型的多态行为感兴趣,在这种情况下,定义一个协议和两种不同的记录类型可能是有意义的,它们一起给出了多态sum 类型的行为:

      (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
      

      我认为这将提供您可能希望从 sum 类型中获得的几乎所有好处。

      这种方法的其他优点:

      • Clojure 中的记录和协议非常惯用
      • 出色的性能(因为协议调度得到了高度优化)
      • 您可以在协议中添加对 nil 的处理(通过extend-protocol

      【讨论】:

      • 谢谢。当值具有统一的行为时,这会有所帮助,但当行为不同时(假设值是“错误消息”或 Double)则无济于事。在我的工作中,我可以摆脱协议。
      • @sastinin - 这种方法适用于值类型完全不同的情况 - 例如,您可以将协议分别扩展为 java.lang.String 和 java.lang.Double。它不起作用的唯一情况是您需要发送除类型以外的其他内容(但您始终可以像上面的示例中那样包装记录类型)
      【解决方案6】:

      如果没有完成像typed clojure 这样令人兴奋的事情,我认为您无法避免运行时检查断言。

      clojure 提供的一个鲜为人知的功能绝对有助于运行时检查,它是前置条件和后置条件的实现(参见http://clojure.org/special_formsa blog post by fogus)。我认为您甚至可以使用具有前置条件和后置条件的单个高阶包装函数来检查您对相关代码的所有断言。这很好地避免了运行时检查“污染问题”。

      【讨论】:

      【解决方案7】:

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

      [: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))
      

      这比其他答案更简洁。

      This youtube talk 更详细地解释了为什么向量对于在地图上编码变体是可取的。我的总结是使用地图编码变体是有问题的,因为您必须记住地图是“标记地图”而不是常规地图。要正确使用“标记地图”,您必须始终进行两阶段查找:首先是标记,然后是基于标记的数据。如果(when)您忘记在映射编码的变体中查找标签,或者为标签或数据获取错误的键查找,您将获得难以追踪的空指针异常。

      该视频还涵盖了矢量编码变体的这些方面:

      • 捕获非法标签。
      • 如果需要,使用 Typed Clojure 添加静态检查。
      • 将此数据存储在Datomic

      【讨论】:

        猜你喜欢
        • 2018-01-12
        • 2018-01-24
        • 2020-02-16
        • 2022-12-31
        • 2012-03-18
        • 1970-01-01
        • 1970-01-01
        • 2013-06-12
        • 2023-02-22
        相关资源
        最近更新 更多