Skip to content

Latest commit

 

History

History
289 lines (220 loc) · 12.1 KB

2020-07-17.md

File metadata and controls

289 lines (220 loc) · 12.1 KB

Learning Clojure in Public - Week 4 Day 5 (26/35)

Expectations

Today I had a lot more time to study Clojure. I aimed to make good progress. First of all, I wanted to finish Brave Clojure, the last chapter of the book which is about multimethods, records and protocols. After that I wanted to tackle the re-frame tutorial on purelyfunctional.tv to get more in-depth knowledge of re-frame than what I can gather by blinking really hard at the Athens codebase.

What I learned

Abstractions in Clojure is probably my favorite thing. An abstraction is a collection of operations, and data types implement abstractions. For example, the seq abstraction consists of operations like first and rest, and the vector data type is an implementation of that abstraction; it responds to all of the seq operations. A specific list or vector is an instance of that data type.

Learning that a data structure is of an abstraction, you can already apply your pre-existing knowledge to it.

Polymorphism

With polymorphism you can have the same function applying different algorithms, depending on the data structure it is working on.

Multimethods

Multimethods let you introduce polymorphism in your code, easily.

(ns were-creatures)
(defmulti full-moon-behavior (fn [were-creature] (:were-type were-creature)))
(defmethod full-moon-behavior :wolf
  [were-creature]
  (str (:name were-creature) " will howl and murder"))
(defmethod full-moon-behavior :simmons
  [were-creature]
  (str (:name were-creature) " will encourage people and sweat to the oldies"))

(full-moon-behavior {:were-type :wolf
                     :name "Rachel from next door"})
; => "Rachel from next door will howl and murder"

(full-moon-behavior {:name "Andy the baker"
                     :were-type :simmons})
; => "Andy the baker will encourage people and sweat to the oldies"

And if you want to have an else case, set nil as your dispatch value, or even a default one:

(defmethod full-moon-behavior nil
  [were-creature]
  (str (:name were-creature) " will stay at home and eat ice cream"))

(full-moon-behavior {:were-type nil
                     :name "Martin the nurse"})
; => "Martin the nurse will stay at home and eat ice cream"

(defmethod full-moon-behavior :default
  [were-creature]
  (str (:name were-creature) " will stay up all night fantasy footballing"))

(full-moon-behavior {:were-type :office-worker
                     :name "Jimmy from sales"})
; => "Jimmy from sales will stay up all night fantasy footballing"```
Now this examples takes multiple arguments:
```clojure
(ns user)
(defmulti types (fn [x y] [(class x) (class y)]))
(defmethod types [java.lang.String java.lang.String]
  [x y]
  "Two strings!")

(types "String 1" "String 2")
; => "Two strings!"

This is why they are called multimethods: they allow dispatch of multiple arguments.

Protocols

Protocols are optimized for type dispatch (like vector vs list). It is created with defprotocol. It is setup like this, they are just declared, it doesn't contain the body yet. By defining a protocol, you’re defining an abstraction, but you haven’t yet defined how that abstraction is implemented.

(ns data-psychology)
(defprotocol Psychodynamics
  "Plumb the inner depths of your data types"
  (thoughts [x] "The data type's innermost thoughts")
  (feelings-about [x] [x y] "Feelings about self or other"))```
You can fix this sorry state of affairs by __extending__ the string data type to __implement__ the Psychodynamics protocol:
```clojure
(extend-type java.lang.String
  Psychodynamics
  (thoughts [x] (str x " thinks, 'Truly, the character defines the data type'")
  (feelings-about
    ([x] (str x " is longing for a simpler way of life"))
    ([x y] (str x " is envious of " y "'s simpler way of life"))))

(thoughts "blorb")
➎ ; => "blorb thinks, 'Truly, the character defines the data type'"

(feelings-about "schmorb")
; => "schmorb is longing for a simpler way of life"

(feelings-about "schmorb" 2)
; => "schmorb is envious of 2's simpler way of life"
    ```
If you want a default protocol for a type you have not extended yet, you can extend `java.lang.Object`, then you can use the protocols on other types:
```clojure
(extend-type java.lang.Object
  Psychodynamics
  (thoughts [x] "Maybe the Internet is just a vector for toxoplasmosis")
  (feelings-about
    ([x] "meh")
    ([x y] (str "meh about " y))))

(thoughts 3)
; => "Maybe the Internet is just a vector for toxoplasmosis"

(feelings-about 3)
; => "meh"

(feelings-about 3 "blorb")
; => "meh about blorb"

Finally extend-protocol let's you extend multiple types at the same time:

(extend-protocol Psychodynamics
  java.lang.String
  (thoughts [x] "Truly, the character defines the data type")
  (feelings-about
    ([x] "longing for a simpler way of life")
    ([x y] (str "envious of " y "'s simpler way of life")))

  java.lang.Object
  (thoughts [x] "Maybe the Internet is just a vector for toxoplasmosis")
  (feelings-about
    ([x] "meh")
    ([x y] (str "meh about " y))))

Records

Clojure allows you to create records, which are custom, maplike data types.

(ns were-records)
(defrecord WereWolf [name title])

Then instances are created like this:

(WereWolf. "David" "London Tourist")
; => #were_records.WereWolf{:name "David", :title "London Tourist"}

(->WereWolf "Jacob" "Lead Shirt Discarder")
; => #were_records.WereWolf{:name "Jacob", :title "Lead Shirt Discarder"}

(map->WereWolf {:name "Lucian" :title "CEO of Melodrama"})
; => #were_records.WereWolf{:name "Lucian", :title "CEO of Melodrama"}```
Then to look up the instances:
```clojure
(def jacob (->WereWolf "Jacob" "Lead Shirt Discarder"))
(.name jacob)
; => "Jacob"

(:name jacob)
; => "Jacob"

(get jacob :name)
; => "Jacob"

In general, you should consider using records if you find yourself creating maps with the same fields over and over. This tells you that that set of data represents information in your application’s domain, and your code will communicate its purpose better if you provide a name based on the concept you’re trying to model.

Learning re-frame

re-frame is basically a loop. There are six steps in a re-frame loop: first an event is dispatched, it goes to the event handler which calculates a side effect which goes to the third step, the effect handling. After this, a query is run on the application state to pass the correct and updated (potentially) information to the view. The view is many ViewFunctions which collectively render the application. It subscribes to result of the query given by step 4. The final step is the DOM, Reagent/React take care of it.

The state

In re-frame, the state lives in app-db, and app-db is simply a Reagent atom. It acts as an in memory database. It's automatically created by re-frame. It's unique across the application and all the components have access to it.

  • It's unique
  • it can be updated with a single reset!, the application will never find itself in an inconsistent state.
  • it can be checked against a schema
  • undo/redo are easy to implement We start at the Reagent component:
(defn delete-button
  [item-id]
  [:div.garbage-bin
    :on-click #(re-frame.core/dispatch [:delete-item item-id])])

The :delete-item has been registered on startup by the event handler:

(re-frame.core/reg-event-fx   ;; a part of the re-frame API
  :delete-item                ;; the kind of event
  h)                          ;; the handler function for this kind of event

The function h is an event handler and will update the db:

(defn h                          ;; maybe choose a better name like `delete-item`
 [coeffects event]               ;; `coeffects` holds the current state of the world
 (let [item-id  (second event)   ;; extract id from event vector
       db       (:db coeffects)  ;; extract the current application state
       new-db   (dissoc-in db [:items item-id])]   ;; new app state
   {:db new-db}))                ;; a map of the necessary effects

An effect handler will also have been registered on startup and will ...handle the effect:

(re-frame.core/reg-fx    ;; re-frame API
  :wear        ;; the effects key which this handler can action
  (fn [val]    ;; val would be, eg, {:pants "velour flares"  :belt false}
    ...))      ;; do what's necessary to action the side effect

The next part is taking in the state and will "extracts" data from application state, and then computes "a materialized view" of the application state - producing data which is useful to the view functions.

(defn query-fn
  [db v]         ;; db is the current value in app-db, v the query vector
  (:items db))   ;; not much of a materialised view

query-fn must be registered on startup also, like this:

(re-frame.core/reg-sub  ;; part of the re-frame API
   :query-items         ;; query id
   query-fn)            ;; function to perform the query

For the view step, any view (function) which uses a (subscribe [:query-items]) is called automatically (reactively) to re-compute new DOM (in response to a change in its source data). Here is the item, the DOM elements are handled by Reagent/React without any more work:

(defn items-view
  []
  (let [items  (subscribe [:query-items])]  ;; source items from app state
    [:div (map item-render @items)]))   ;; assume item-render already written

Troubleshooting our issue

Our issue has moved here, and we made some progress. We are now showing the right behavior when using the arrow keys: the selected item never goes out of focus and the viewport adjusts to the selected item when it's about to get out of the viewport. It's also no longer possible to go to non-existent indexes (say continuing to press down when there's no other element). Without further ado here is the code currently:

      (swap! state update :index dec)
      (when (> index 0)
        (swap! state update :index dec)
        (let [select-el (first (array-seq (. js/document getElementsByClassName "selected")))
              next-el (.. select-el -previousElementSibling)
              athena-el (first (array-seq (. js/document getElementsByClassName "athena")))
              result-el (nth (array-seq (.. athena-el -children)) 2)
              result-box (.. result-el getBoundingClientRect)
              next-box (.. next-el getBoundingClientRect)]
          (when (< (.. next-box -top) (.. result-box -top))
            (.. next-el (scrollIntoView true {:behavior "auto"})))))

      (= key KeyCodes.DOWN)
      (swap! state update :index inc)
      (when (< index (dec (count results)))
        (swap! state update :index inc)
        (let [select-el (first (array-seq (. js/document getElementsByClassName "selected")))
              next-el (.. select-el -nextElementSibling)
              athena-el (first (array-seq (. js/document getElementsByClassName "athena")))
              result-el (nth (array-seq (.. athena-el -children)) 2)
              result-box (.. result-el getBoundingClientRect)
              next-box (.. next-el getBoundingClientRect)]
          (when (> (.. next-box -bottom) (.. result-box -bottom))
            (.. next-el (scrollIntoView false {:behavior "auto"})))))

There are still a couple of issues, like a flickering of the selection, like when scrolling is necessary.

Takeaways

One of Clojure’s design principles is to write to abstractions. In this chapter, I learned how to define my own abstractions using multimethods and prototypes. These constructs provide polymorphism, allowing the same operation to behave differently based on the arguments it’s given.

I also read the beginning of the re-frame documentation which was very instructive, and sheds a lot of light on how and why Athens is structured like this.

Let's end the day with a tally of what I completed:

  • Finished Clojure for the Brave and True, by reading its last chapter (Chapter 13)
  • Continued troubleshooting our community issue
  • Read the beginning of the re-frame documentation, which was very informative