Clojure 域建模:规范与协议

Clojure Domain Modeling: Spec vs. Protocols

这个问题变得很长;我欢迎为这个问题建议更好的论坛的评论。

我正在为 swarming behavior of birds 建模。为了帮助我整理思路,我创建了三个协议来代表我看到的主要领域概念:BoidFlock(类群集合)和 Vector.

随着我对它的深入思考,我意识到我正在创建新类型来表示 BoidFlock,而这些可以使用规范映射非常干净地建模:boid 是位置和速度(均为向量)的简单映射,而群是 boid 映射的集合。干净、简洁、简单,并消除了我的自定义类型,以支持地图和 clojure.spec.

的所有功能
(s/def ::position ::v/vector)
(s/def ::velocity ::v/vector)
(s/def ::boid (s/keys ::position
                      ::velocity))
(s/def ::boids (s/coll-of ::boid))

但是,虽然类群很容易表示为一对向量(并且一群可以表示为类群的集合),但我对如何为向量建模感到困惑。我不知道我是否想使用笛卡尔坐标或极坐标来表示我的矢量,所以我想要一种允许我抽象掉该细节的表示。无论我如何在引擎盖下存储矢量分量,我都想要矢量函数的基本代数。

(defprotocol Vector
  "A representation of a simple vector. Up/down vector? Who cares!"
  (magnitude [vector] "Returns the magnitude of the vector")

  (angle [vector] "Returns the angle of the vector (in radians? from what
  zero?).")

  (x [vector] "Returns the x component of the vector, assuming 'x' means
  something useful.")

  (y [vector] "Returns the y component of the vector, assuming 'y' means
  something useful.")

  (add [vector other] "Returns a new vector that is the sum of vector and
  other.")

  (scale [vector scaler] "Returns a new vector that is a scaled version of
  vector."))

(s/def ::vector #(satisfies? Vector %))

除了一致性美学之外,这种差异困扰我的最大原因是生成测试:我还没有做过,但我很高兴学习,因为它可以让我在规范后测试我的更高级别的功能是我的低级原语。问题是,我不知道如何在不将抽象 protocol/spec 耦合到定义功能的具体记录的情况下为 ::vector 规范创建生成器。我的意思是,我的生成器需要创建一个 Vector 实例,对吗?要么我 proxy 生成器中的某些东西,因此创建一个不必要的 Vector 实现只是为了测试,或者我将我很好的抽象 protocol/spec 与具体实现相结合。

问题:如何使用规范对矢量(行为集比特定数据表示更重要的实体)建模?或者,如何在不将规范绑定到具体实现的情况下为基于协议的规范创建测试生成器?

更新 #1: 为了以不同的方式解释它,我创建了一个分层数据模型,其中特定层仅根据其下方的层编写。 (这里没什么新奇的。)

Flock (functions dealing with collections of boids)
----------------------------------------------------
Boid (functions dealing with a single boid)
----------------------------------------------------
Vector

由于这个模型,删除所有更高的抽象将使我的程序变成 Vector 操作。这个事实的一个理想推论:如果我能找出一个 Vectors 生成器,我就可以免费测试我所有的高级抽象。那么我该如何指定 Vector 并创建合适的测试生成器呢?

明显但不充分的答案:创建一个规范 ::vector 来表示一对坐标的映射,比如 (s/keys ::x ::y)。但为什么 (x, y)?如果我可以访问 (angle, magnitude),一些计算会更容易。我可以创建 ::vector 来表示一对坐标,但是那些需要 other 表示的函数必须知道并关心向量在内部的存储方式,因此必须知道达到外部转换功能。 (是的,我可以使用 multispec/conform/multimethods 来实现这个,但是使用这些工具听起来像是一种不必要的漏洞抽象;我不希望更高的抽象知道或关心 Vectors 可以用多种方式表示。)

更基本的是,向量不是 (x, y)(angle, magnitude),它们只是 "real" 向量的投影,但是您想如何定义它。 (我说的是领域建模,而不是数学严谨性。)因此,创建一个将向量表示为一对坐标的规范不仅在这种情况下是一种糟糕的抽象,而且它并不代表领域实体。

更好的选择是我在上面定义的协议。所有更高的抽象都可以根据 Vector 协议编写,为我提供了一个干净的抽象层。但是,如果不将我的抽象与具体实现相结合,我就无法创建一个好的 Vector 测试生成器。也许这是我必须做出的权衡,但有更好的方法来建模吗?

虽然这个问题肯定有很多有效答案,但我建议您重新考虑您的目标。

通过在规范中支持这两种坐标表示,您声明它们同时受支持。这将不可避免地导致像运行时多态性这样的复杂性开销。例如您的 Vector 协议需要为 Cartesian/Cartesian、Cartesian/Polar、Polar/Cartesian、Polar/Polar 实施。在这一点上,实现是耦合的,你没有得到 "seamlessly" 在表示之间交替的预期好处。

我会满足于一种表示,并在必要时使用外部转换层。

从我们在评论中的讨论来看,您似乎更喜欢使用协议的多态性。我想我明白你想做什么,并会尽力回应。

假设您有矢量界面:

(defprotocol AbstractVector

  ;; method declarations go here...

  )

声明 AbstractVector 协议时,我们不需要了解该协议的任何具体实现。除了这个协议,我们还将实现收集规范的地方:

(defonce concrete-spec-registry (atom #{}))

(defn register-concrete-vector-spec [sp]
  (swap! concrete-spec-registry conj sp))

现在我们可以为各种类:

实现这个协议
(extend-type clojure.lang.ISeq
  AbstractVector

  ;; method implementations go here...

  )

(extend-type clojure.lang.IPersistentVector
  AbstractVector

  ;; method implementations go here...

  )

但我们还需要提供可用于为这些实现生成示例的规范:

(spec/def ::concrete-vector-implementation (spec/cat :x number?
                                                     :y number?))
(register-concrete-vector-spec ::concrete-vector-implementation)

让我们为抽象向量定义一个规范,首先编写一个函数来测试某物是否是抽象向量:

(defn abstract-vector? [x]
  (satisfies? AbstractVector x))

;; (assert (abstract-vector? []))
;; (assert (not (abstract-vector? {})))

或者,这样实现可能更准确:

(defn abstract-vector? [x]
  (some #(spec/valid? % x)
        (deref concrete-implementation-registry)))

这是规范以及生成器:

(spec/def ::vector (spec/with-gen (spec/spec abstract-vector?)
                     #(gen/one-of (mapv spec/gen (deref concrete-spec-registry)))))

在上面的代码中,我们取消引用持有具体规范的原子,然后在这些规范之上构建一个生成器,该生成器将使用其中一个生成。这样,我们不需要知道存在哪些具体的矢量实现,只要它们的源代码已经加载并且 register-concrete-vector-spec 函数已经被用来注册特定的规范。

现在我们可以生成样本了:

(gen/generate (spec/gen ::vector))
;; => (-879 0.011494353413581848)