Skip to content

Instantly share code, notes, and snippets.

@olivergeorge
Last active December 23, 2016 22:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save olivergeorge/85c2b263227676324275ce13408c0fb8 to your computer and use it in GitHub Desktop.
Save olivergeorge/85c2b263227676324275ce13408c0fb8 to your computer and use it in GitHub Desktop.

Context:

Currently writing a function spec is a bit like verbose destructing - it can require as much code as the function itself.

Reduced effort in spec'ing functions would be nice.

Spec encourages namespaced keywords to have one "type". That means within your program any map with that key can be assumed to have a predictable type.

A similar convention is to have one meaning for a variable name in a specific namespace. Nothing in spec about that... it's just less confusing.

With those reference points, here's the genius idea:

Idea:

Use the arglist to generate a function spec. It gets more interesting if we translate arglist destructing into specs too.

e.g. That means (defn x [a b c]) will check:

  • [0] conforms to ::a
  • [1] conforms to ::b
  • [2] conforms to ::c

e.g. That means (defn x [{:keys [url x] :or {x 1} [_ request limit count]]) will check:

  • [0] conforms to (s/keys :req-un [::url] :opt-un [::x])
  • [1] conforms to coll?
  • [1 1] conforms to ::request
  • [1 2] conforms to ::limit
  • [1 3] conforms to ::count

Weaknesses:

Destructing keys involves a lot of nil punning. e.g. if key is missing, it's okay. We need to pick between using :opt-un or :req-un. Simple unambiguous logic would be:

  • If there's an :or clause we use :opt-un
  • Else it is a required key and we use :req-un.

Perhaps the key spec can guide this. A more flexible/relaxed approach would be "if the spec is s/nilable then we could use :opt-un and know our fn is getting predictable arg data". That wouldn't pick up expected but missing keys so let's be conservative for now and consider relaxing this later.

Implementation:

The function var has :arglists metadata. That would allow functions to be spec'd without replacing the defn macro.

(defn myfn  [a & bs] a)
=> #'cljs.user/myfn

(:arglists (meta (var myfn)))
=> ([a & bs])

Ultimately, you might typically put specs at the top of each namespace like this...

(ns example ...)

(s/def ::base-url string?)
(s/def ::request (s/keys :req-un [::url ::params]))
(s/def ::page-limit pos-int?)
(s/def ::page-count pos-int?)

(defn load-options
  [{:keys [base-url]} [_ request page-limit page-count]]
  ...)

; Instrumenting these might look like this
(instrument-from-arglists *ns*)

In 1.9, clojure.core.specs includes an ::arg-list spec which includes destructuring in the binding-form. That should ease parsing arg-lists into a useful AST to transform.

https://github.com/clojure/clojure/blob/master/src/clj/clojure/core/specs.clj#L67

@luxbock
Copy link

luxbock commented Dec 22, 2016

Wouldn't this limit all of your functions to only work with specs that are defined within the same namespace as the functions themselves?

I've been spending hammock time on the "alternative defn macro" approach, and here's what I've got so far:

  • Use a superset of the regular destructuring syntax that allows you to optionally introduce specs for symbols by wrapping both the spec and the symbol inside parentheses. For example: (::foo a-foo), (#{1 2 3} n) or (string? x). These are valid to use whenever a symbol can occur in in the destructuring syntax. The order of the spec / symbol could go both ways, but I quite like how (string? x) reads as a part of the parameter declaration.

  • Add additional map destructuring keyword directive that maps to the s/keys keyword arguments:

    • :req / :req-un => :keys-req
    • :opt :opt-un => :keys
  • The choice between qualified and unqualified keywords can be made based on the vector that follows the directive.

  • I find that a lot of the verbosity of defining function specs via s/fdef comes from having to always wrap :args inside s/cat and giving names to positional arguments, which we are already doing by naming the parameters of our function via symbols.

Some examples of what an implementation of these ideas might look like:

  • Use any? as the default spec for symbols without specs:
    [(string? x) y (int? z)] => (s/cat :x string? :y any? :z int?)

  • Variable arguments:
    [(string? x) & ((s/cat :xs (s/+ int?)) rs)]
    =>
    (s/cat :x string? :rs (s/cat :xs (s/+ int?)))

  • Regular map destructuring gives you some default names for things, but won't try to infer anything more than what's promised (so anything could be nil and therefore optional):
    [{:keys [foo]}] => (s/cat :m (s/keys :opt-un [::foo]))
    [{:keys [foo] :as foo-map}] => (s/cat :foo-map (s/keys :opt-un [::foo]))
    [{:foo/keys [bar]}] => (s/cat :m (s/keys :opt [:foo/bar]))

  • By assigning a spec to a symbol, that symbol now becomes required. The following implementation would have the downside that your destructuring syntax may end up introducing specs that shadow already defined value-specs:
    [{:keys [(string? foo)]}]
    =>
    (s/def ::foo string?)
    (s/cat :m (s/keys :req-un [::foo]))

  • Therefore it's probably better if new value level specs are only defined when used with :keys-req, in which case every symbol must come with a spec (this makes implementation a bit simpler as well):
    [{:keys [(string? foo)]}] => error
    [{:keys-req [foo]}] => error
    [{:keys-req [(string? foo)] :keys [:bar/x y]}]
    =>
    (s/def ::foo string?)
    (s/cat :m (s/keys :req [::foo] :opt [:bar/x] :opt-un [::y]))

We also want a way to easily define the :ret and :fn of a function within its definition, so right now I'm leaning towards including it as a part of the meta-map of the defn declaration, and then removing it from there when expanding the actual function body:

(defns foobar
  {:ret string?
   :fn (fn [{:keys [ret]}] (pos? (count ret)))} ;; silly example
  [(int? x) (int? y)]
  (str (* x y)))

An interesting feature for the future might be to add a third keyword such as :mode based on which the passed in specs also generate :pre and :post conditionals for the expanded function body.

@olivergeorge
Copy link
Author

Thanks @luxbock I'll think about this over the break

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment