I'm not sure if this is a beginner question, but I'm not sure where this would go. I've been utilizing multimethods much more recently, and I'm curious about how to handle the case where a dispatch function does not find a matching multimethod. I know that if you don't define something like (defmethod f :deafult ...) an exception is thrown. Is there an idiomatic way to deal with this? Should I define the default case and throw an exception that contains detailed information, or should I return some data that describes the error? I realize that some of this is subjective, but I would appreciate any opinions on this.
A concrete example is something like this:
(defn action-type
"Returns the action type of the given rule."
[rule]
(get-in rule [:rule/action :action/type]))
(defn action-params
"Returns the action params of the given rule."
[rule]
(get-in rule [:rule/action :action/params]))
(defmulti apply-action
"Applies the given action to the game state and
returns the new game-state. If no dispatcher is found,
returns the game-state unchanged.
Args:
* game-state: The current game state
* rule: The rule to apply
Dispatchers:
* :deal - Deals the given number of cards to the given target from the given source.
* :transition-game-status - Sets the game status to the given status.
* :transition-phase - Sets the game phase to the given phase.
* :transition-player - Sets the current player to the next player.
* :card-management - Resets the deck state and player state"
(fn [_ rule] (action-type rule)))
(defmethod apply-action :deal
[game-state rule]
(let [params (action-params rule)]
(deal-action game-state params)))
(defmethod apply-action :transition-game-status
[_ rule]
(let [params (action-params rule)]
[[:state/assoc :game/status (:status params)]]))
(defmethod apply-action :transition-phase
[_ rule]
(let [params (action-params rule)]
[[:state/assoc :game/phase (:phase params)]]))
(defmethod apply-action :default
[_ rule]
[[:game/handle-error {:type :apply-action
:message "Failed to apply action"
:errors [{:type :unknown-action-type
:message "Unknown action type"
:value (action-type rule)}]}]])This a subset of the code
that looks pretty good to me, tbh
The choice is not specific to multimethods.
It's pretty much the same choice that you have to make when deciding on whether you should be using an assertion, throwing an exception, returning some error value, returning no value but posting an error via some other mechanism.
Depends on the kind of error, depends on the logic, on the scenario.
If a failure was caused by a missing defmethod, it's probably an error on the programmer's side. So it should probably use assert, and there should be some generic mechanism downstream to report any such errors to users as opaque "oopsy-daisy, HTTP 500, we'll take a look".
> I've been utilizing multimethods much more recently
I would also like to use this opportunity to mention that if those multimethods are only ever extended within the very same code where defmulti exists, please reconsider using them.
They trivially become a hammer in search for a nail, I've seen this so many times I think I might've developed an allergy.
Assuming your code above is all in a single namespace and defmethod doesn't happen outside, just use case or cond.
Of course, don't lump all the actions together - have some separate functions, so it becomes something like
(defn apply-deal-action [params]
...)
(defn apply-transition-game-status-action [params]
...)
(defn apply-transition-phase-action [params]
...)
(defn apply-action [game-state rule]
(let [f (case (action-type rule)
:deal apply-deal-action
:transition-game-status apply-transition-game-status-action
:transition-phase apply-transition-phase-action)
params (action-params rule)]
(f params)))
case handles the failure automatically (use an explicit throw if you'd like to add more details, like e.g. reporting the whole rule and not just the action type).
All the possible dispatch values are right there, in a single form - the whole domain is visible, you don't have to hunt for it or keep it all in your working memory.
Smaller probability of missing some dispatch value.
The stack traces are cleaner - real function names instead of an opaque apply-action by itself. Yes, you can give every defmethod a proper name since it accepts all the same bits that fn does, but that would also result in repetition.
Impossible to create dumb situations where you move some defmethods into different namespaces but due to the load order or due to a missing :require those won't get used at the right time or at all, leading to immediate failures at best and non-deterministic race conditions at worst.
Much less incentive to be clever with dynamically used defmethods or, god forbid, hierarchies.
I could probably go on.
But multimethods are totally cool if they're used for external extensibility. Like e.g. what Integrant uses them for.The reason I went with the multimethods was that further up, I wanted a single method that I could call to apply an action. The next level up, I have this:
(defn rule-type
[rule]
(:rule/type rule))
(defmulti apply-rule
(fn [_ rule] (rule-type rule)))
(defmethod apply-rule :apply
[game-state rule]
(apply-action game-state rule))
(defmethod apply-rule :if-then
[game-state rule]
(if (check-condition game-state rule)
(apply-action game-state rule)
[]))
(defmethod apply-rule :if-then-else
[game-state rule]
(if (check-condition game-state rule)
(apply-action game-state rule)
(apply-action game-state (:rule/else rule))))
These multimethods could be used externally, but right now they are not. I could see how all this could be done with a case, too, though.
The other part of this is that I was kind of designing towards a single point where the state was actually changed or errors were handled. That's why I was asking about returning errors as data, but I realize that I could handle exceptions there just as easily.
Mainly, I wasn't sure if there was an idiomatic way to handle this scenario, but it sounds like exceptions are the way to go.For reference, all the code lives here -> https://github.com/andrewleverette/card-engine. The system doesn’t work completely at the moment, since I’m in the middle of a refactor.
> The system doesn’t work completely at the moment, since I’m in the middle of a refactor. If it used to work and now it's broken, whatever you're doing is not refactoring. I'll second the advice to move away from multimethods if external extensibility is not a critical feature of your system. I've had relatively good experiences using "variants" (as defined https://www.youtube.com/watch?v=ZQkIWWTygio) with https://github.com/clojure/core.match for that kind of "internal" dispatch, especially since you seem to always dispatch on the "same" keyword (so pulling it out in front seems feasible), though YMMV.
I’m re-working how state is managed in the application. It’s not done yet, I’m just committing as I complete sections.