"Hose-and-sprinkler" multimethods

Curious if others have used this pattern, what it’s called, and if people have any strong thoughts about it.

I call the pattern “hose-and-sprinkler” multimethods because you have a pipeline (“hose”) of one or more methods every call goes through before fanning out (“sprinkler”) in the traditional multimethod manner depending on the args.

The pipeline “hose” stage is useful for input/output grooming that is desired across all the “sprinkler” methods, for example removing interim keys from an output map, adding args, verifying/rewriting input, doing a top-level channel take, etc.

The alternative would be to place a distinct function in front of the multimethod to do pre/post processing. The advantage of “hose and sprinkler” over this is

  1. if the multimethod has existed for some time, you don’t need to change callers to use a new fn (or implementers to use a new multimethod name)

  2. callers can choose “hose” stages with some granularity. For example, in the example below, if I have done verification elsewhere, I can call the matches multimethod with
    {...:verifying? true...},
    or if I want to take the go channel myself in a non blocking fashion I can call it with
    {...:taking? true...}

Here is a toy example. The multimethod matches recursively matches a string (target) against one or more regexes or against one or more substring offsets (indices). There are some silly things (like the superflous go and the fact that dispatch is closed rather than extensible) but this illustrates the “hose and sprinkler” pattern reasonably well:

(ns com.example.foo
  (:require
   [clojure.spec.alpha :as s]
   [clojure.core.async :refer [go <!!]]))

(s/def ::target string?)

(s/def ::regex (partial instance? java.util.regex.Pattern))
(s/def ::regexes (s/coll-of ::regex))

(s/def ::offsets (s/tuple integer? integer?))
(s/def ::indices (s/coll-of ::offsets))

(s/def ::matcher (s/keys :req-un [::target (or ::regexes ::indices)]))

(defmulti matches
  (fn [{:keys [regexes indices taking? verifying? wrapping?] :as args}]
    (cond 
      (not verifying?) :verify ;hose
      (not wrapping?) :wrap ;hose
      (not taking?) :take ;hose
      regexes :regexes ;sprinkler
      indices :indices ;sprinkler - usually open/extensible
      )))

;;hose
(defmethod matches :verify
  [args]
  (if (s/valid? ::matcher args)
    (matches (assoc args :verifying? true))
    {:error (s/explain-data ::matcher args)}))

;;hose
(defmethod matches :wrap
  [args]
  (let [{:keys [last-target] matches :target} (matches (assoc args :wrapping? true))]
    (if matches
      {:matches (if (sequential? matches) matches [matches])}
      {:failed-on last-target})))

;;hose
(defmethod matches :take
  [args]
  (<!! (matches (assoc args :taking? true))))

;;sprinkler
(defmethod matches :regexes
  [{:keys [regexes target]}]
  (go (reduce (fn [{:keys [target] :as result} regex]
                (if target
                  {:target (not-empty (re-seq regex (first target)))
                   :last-target (first target)}
                  result))
              {:target [target]}
              regexes)))

;;sprinkler 
(defmethod matches :indices
  [{:keys [indices target]}]
  (go (reduce (fn [{:keys [target] :as result} offsets]
                (if target
                  {:target (try (apply subs target offsets)
                                (catch java.lang.StringIndexOutOfBoundsException e))
                   :last-target target}
                  result))
              {:target target}
              indices)))

;; com.example.foo> (matches {:regexes [#"\w[?!]{2,}" #"[?!]"]
;;                            :target "A concurrency macro? In THIS economy??!"})
;; {:matches ("?" "?" "!")}
;; com.example.foo> (matches {:indices [[1 9] [0 2]]
;;                            :target "A concurrency macro? In THIS economy??!"})
;; {:matches [" c"]}


Interesting pattern, thanks. I have not used it before. My convention is to see multimethods as private and always have a wrapping function that callers use.

1 Like

This topic was automatically closed 182 days after the last reply. New replies are no longer allowed.