How can I elegantly combine resource and exception

2019-07-27 02:53发布

问题:

I'm writing a Clojure wrapper for an object-oriented API that heavily involves resource handling. For instance, for the Foo object, I've written three basic functions: foo?, which returns true iff something is a Foo; create-foo, which attempts to obtain the resources to create a Foo, then returns a map containing a return code and (if the construction succeeded) the newly created Foo; and destroy-foo, which takes a Foo and releases its resources. Here are some stubs for those three functions:

(def foo? (comp boolean #{:placeholder}))

(defn create-foo []
  (let [result (rand-nth [::success ::bar-too-full ::baz-not-available])]
    (merge {::result result}
           (when (= ::success result)
             {::foo :placeholder}))))

(defn destroy-foo [foo] {:pre [(foo? foo)]} nil)

Obviously, every time create-foo is called and succeeds, destroy-foo must be called with the returned Foo. Here's a simple example that doesn't use any custom macros:

(let [{:keys [::result ::foo]} (create-foo)]
  (if (= ::success result)
    (try
      (println "Got a Foo:")
      (prn foo)
      (finally
        (destroy-foo foo)))
    (do
      (println "Got an error:")
      (prn result))))

There's a lot of boilerplate here: the try-finally-destroy-foo construct must be present to ensure that all Foo resources are released, and the (= ::success result) test must be present to ensure that nothing gets run assuming a Foo when there is no Foo.

Some of that boilerplate can be eliminated by a with-foo macro, similar to the with-open macro in clojure.core:

(defmacro with-foo [bindings & body]
  {:pre [(vector? bindings)
         (= 2 (count bindings))
         (symbol? (bindings 0))]}
  `(let ~bindings
     (try
       ~@body
       (finally
         (destroy-foo ~(bindings 0))))))

While this does help somewhat, it doesn't do anything about the (= ::success result) boilerplate, and now two separate binding forms are required to achieve the desired result:

(let [{:keys [::result] :as m} (create-foo)]
  (if (= ::success result)
    (with-foo [foo (::foo m)]
      (println "Got a Foo:")
      (prn foo))
    (do
      (println "Got an error:")
      (prn result))))

I simply can't figure out a good way to handle this. I mean, I could complect the behaviors of if-let and with-foo into some sort of if-with-foo macro:

(defmacro if-with-foo [bindings then else]
  {:pre [(vector? bindings)
         (= 2 (count bindings))]}
  `(let [{result# ::result foo# ::foo :as m#} ~(bindings 1)
         ~(bindings 0) m#]
     (if (= ::success result#)
       (try
         ~then
         (finally
           (destroy-foo foo#)))
       ~else)))

This does eliminate even more boilerplate:

(if-with-foo [{:keys [::result ::foo]} (create-foo)]
  (do
    (println "Got a Foo:")
    (prn foo))
  (do
    (println "Got a result:")
    (prn result)))

However, I don't like this if-with-foo macro for several reasons:

  • it's very tightly coupled to the specific structure of the map returned by create-foo
  • unlike if-let, it causes all bindings to be in scope in both branches
  • its ugly name reflects its ugly complexity

Are these macros the best I can do here? Or is there a more elegant way to handle resource handling with possible resource obtainment failure? Perhaps this is a job for monads; I don't have enough experience with monads to know whether they would be useful tool here.

回答1:

I'd add an error-handler to with-foo. This way the macro has a focus on what should be done. However, this simplifies the code only when all error-cases are treated by a handful of error handlers. If you have to define a custom error-handler every time you call with-foo this solution makes readability worse than an if-else construct.

I added copy-to-map. copy-to-map should copy all relevant information from the object to a map. This way the user of the macro doesn't by accident return the foo-object, since it gets destroyed inside the macro

(defn foo? [foo]
  (= ::success (:result foo)))

(defn create-foo [param-one param-two]
  (rand-nth (map #(merge {:obj :foo-obj :result %} {:params [param-one param-two]})
                 [::success ::bar-too-full ::baz-not-available])))

(defn destroy-foo [foo]
      nil)

(defn err-handler [foo]
      [:error foo])

(defn copy-to-map [foo]
      ;; pseudo code here
      (into {} foo))

(defmacro with-foo [[f-sym foo-params & {:keys [on-error]}] & body]
  `(let [foo# (apply ~create-foo [~@foo-params])
         ~f-sym (copy-to-map foo#)]
     (if (foo? foo#)
       (try ~@body
            (finally (destroy-foo foo#)))
       (when ~on-error
         (apply ~on-error [~f-sym])))))

Now you call it

(with-foo [f [:param-one :param-two] :on-error err-handler]
    [:success (str "i made it: " f)])


回答2:

Building from @murphy's excellent idea to put the error handler into with-foo's bindings to keep the focus on the normal case, I've ended up with a solution that I like quite a lot:

(defmacro with-foo [bindings & body]
  {:pre [(vector? bindings)
         (even? (count bindings))]}
  (if-let [[sym init temp error] (not-empty bindings)]
    (let [error? (= :error temp)]
      `(let [{result# ::result foo# ::foo :as m#} ~init]
         (if (contains? m# ::foo)
           (try
             (let [~sym foo#]
               (with-foo ~(subvec bindings (if error? 4 2))
                 ~@body))
             (finally
               (destroy-foo foo#)))
           (let [f# ~(if error? error `(constantly nil))]
             (f# result#)))))
    `(do
       ~@body)))
  • like my if-with-foo macro in the question, this with-foo macro is still tied to the structure returned by create-foo; unlike my if-with-foo macro and @murphy's with-foo macro, it eliminates the need for the user to manually take apart that structure
  • all names are properly scoped; the user's sym is only bound in the main body, not in the :error handler, and conversely, the ::result is only bound in the :error handler, not in the main body
  • like @murphy's solution, this macro has a nice, fitting name, instead of something ugly like if-with-foo
  • unlike @murphy's with-foo macro, this with-foo macro allows the user to provide any init value, rather than forcing a call to create-foo, and doesn't transform the returned value

The most basic use case simply binds a symbol to a Foo returned by create-foo in some body, returning nil if the construction fails:

(with-foo [foo (create-foo)]
  ["Got a Foo!" foo])

To handle the exceptional case, an :error handler can be added to the binding:

(with-foo [foo (create-foo)
           :error (partial vector "Got an error!")]
  ["Got a Foo!" foo])

Any number of Foo bindings can be used:

(with-foo [foo1 (create-foo)
           foo2 (create-foo)]
  ["Got some Foos!" foo1 foo2])

Each binding can have its own :error handler; any missing error handlers are replaced with (constantly nil):

(with-foo [foo1 (create-foo)
           :error (partial vector "Got an error!")
           foo2 (create-foo)]
  ["Got some Foos!" foo1 foo2])