Why does Clojure neglect the uniform access principle

clojureencapsulationruby

My background is Ruby, C#, JavaScript and Java. And now I'm learning Clojure. What makes me feel uncomfortable about the later is that idiomatic Clojure seems to neglect the Uniform access principle (wiki, c2) and thus to a certain degree encapsulation as well by suggesting to use maps instead of some sort of "structures" or "classes". It feels like step back. So a couple of questions, if anyone informed:

  • Which other design decisions/concerns it conflicted with and why it was considered less important?
  • Did you have the same concern as well and how it end up when you switched from a language supporting UAP by default (Ruby, Eiffel, Python, C#) to Clojure?

Best Answer

First, a disclaimer: I'm not deeply familiar with the uniform access principle so you may want to take this with a grain of salt. That said, I would argue that Clojure does observe a uniform access principle: function calls.

The key quote on Wikipedia seems to be that "all services offered by a module should be available through a uniform notation, which does not betray whether they are implemented through storage or through computation", and that's exactly the case with function calls in Clojure. In fact, everything in Clojure (and other Lisps) is a function call except for special forms and macros - and macros actually are functions, with the distinction that they operate on source code. You can even specify function call behavior for your own types by implementing clojure.lang.IFn:

(deftype Invokable []
  clojure.lang.IFn
  (invoke [this]
    :was-invoked))

(def invokable (Invokable.))
(invokable)                  ;=> :was-invoked

You may have in mind the use of keywords as functions, which are most specifically associated with hash-maps, but you can actually implement that behavior for different types as well. Here's a silly example where keyword access reads a file (with the same name as the keyword) from your home directory, or returns a default value if such a file doesn't exist:

(ns weird.example
  (:require [clojure.java.io :as io]))

(deftype WeirdKlass []
  clojure.lang.ILookup
  (valAt [this k not-found]
    (let [home (System/getProperty "user.home")
          file (io/file home (name k))]
     (if (.isFile file)
       (slurp file)
       not-found)))
  (valAt [this k]
    (.valAt this k nil)))

(def klass (WeirdKlass.))

;; assuming you have a file at $HOME/testfile
(:testfile klass)        ;=> "test content!"
(:blah klass)            ;=> nil
(:blah klass :not-found) ;=> :not-found

Update based on Alexey's comment

I think I have a better idea what you mean now but in practice I haven't found it to be an inconvenience. When you want or need a level of indirection/abstraction between your code's functionality and its underlying data representation the way to achieve it is: functions!

The simplest way would be to define full-name as a function which happens to use keyword lookup but could later be changed to instead compute the name (without clients knowing or caring):

;; For now
(defn full-name [person]
  (:full-name person))

;; Maybe later
(defn full-name [person]
  (str (:first-name person)
       " "
       (:last-name person)))

Of course, you could also use records and protocols:

(defprotocol IPerson
  (full-name [p])
  (first-name [p])
  (last-name [p]))

(defrecord Person1 [name]
  IPerson
  (full-name [_]
    name)
  (first-name [_]
    (first (.split name " ")))
  (last-name [_]
    (last (.split name " "))))

(def person1 (->Person1 "Joe Schmoe"))
(full-name person1)  ;=> "Joe Schmoe"
(first-name person1) ;=> "Joe"
(last-name person1)  ;=> "Schmoe"

(defrecord Person2 [f-name l-name]
  IPerson
  (full-name [_]
    (str f-name " " l-name))
  (first-name [_]
    f-name)
  (last-name [_]
    l-name))

(def person2 (->Person2 "Joe" "Schmoe"))
(full-name person2)  ;=> "Joe Schmoe"
(first-name person2) ;=> "Joe"
(last-name person2)  ;=> "Schmoe"

The big (very big, in my view) upside of using "plain" data structures when possible is that it means you and your users have Clojure's full range of map and sequence operations at your disposal. Alan Perlis expressed this idea well when he said that "it is better to have 100 functions operate on one data structure than 10 functions on 10 data structures."

Related Topic