Stop. Java Time!
Modeling Time
Java Time and its predecessor Joda Time are the workhorses of countless systems around the world. Accounting for leap years and timezones when calculating payroll and coordinating bank transfers is surprisingly difficult. While most programmers agree that it works well enough, the abstraction leaves something to be desired.
Take something simple like adding a month to a date. The results may be surprising. According to Java:
One month after March 15, 2015 is April 15, 2015.
One month after March 30, 2015 is April 30, 2015.
One month after March 31, 2015 is April 30, 2015.
(defn add-1-month [date]
(-> (java.time.LocalDate/parse date)
(.plusMonths 1)
str))
(add-1-month "2015-03-15")
It both makes sense and feels incorrect. In part because Java uses methods called plusMonths
and plusDays
and we expect addition to be associative: inline_formula not implemented.
Adding a day and then adding a month should be the same as adding a month and then adding a day. But it's not always true. Take the example of May 30th:
(defn add-1-day [date]
(-> (java.time.LocalDate/parse date)
(.plusDays 1)
str))
(add-1-month (add-1-day "2015-03-30"))
(add-1-day (add-1-month "2015-03-30"))
And yet Java Time endures. In part it endures because this is a very hard problem. Eric Evans addresses the nuance of time math in his talk Modelling Time. He suggests that arithmetic is the wrong abstraction. Evans describes taking inspiration from Clojure, a list processing language at heart, to develop a different abstraction.
Time a list of units. So July holds a list of days
July 1, July 2, ... July 31
.Intervals is where sequences meet.
July 1
meetsJuly 2
.July
meetsAugust
.2020
meets2021
(if we're lucky).
Evans’ model includes an implementation of Allen’s interval algebra that is beyond the scope of this post. Suffice to say, Java Time is incredibly popular, but there are other approaches that may be more accurate and less complex.
Even something like a regular sequence of intervals - like the tick of the clock - can be unintuitive. In Date and Time are more difficult than you think, Alex Bolboaca and Adrian Bolboaca discuss time zones, the international date line, and relativistic time.
Relativistic time is a real mind-bender with practical consequences. For examples, engineers must take into account the speed of orbiting satellites and the curvature of spacetime when estimating GPS coordinates. To stay in lock-step with clocks on the Earth’s surface, satellite clocks must tick 38 microseconds faster in aggregate every day. Errors in time-keeping can lead to kilometer-sized positioning errors that get worse over time. The GPS on your phone would be worthless if these clocks did not observe the rules of Einstein’s theory of relativity.
java.time
When staring a project with Java 8 or later, use Java Time instead of Joda Time. Stephen Colebourne is brief when he discusses the advantages: "The java.time
library contains many of the lessons learned from Joda-Time, including stricter null
handling and a better approach to multiple calendar systems."
This is what it looks like using Java interop.
(java.time.LocalDateTime/now)
LocalDate
, LocalTime
, and LocalDateTime
will be preferred for these examples. They are easy to work with and human-readable. Other options include Instant
(machine time since the Unix epoch (1970-01-01T00:00:00Z
)) and Zoned
(timezones) times.
java.time Wrappers
We're Clojure programmers and we like wrappers! There are two main libraries for wrapping java.time, Clojure.Java-Time and cljc.java-time.
Clojure.Java-Time
Clojure.Java-Time
is a popular Clojure wrapper for Java 8 Date-Time API. It focuses on the JVM. You're on your own if you want to interoperate with ClojureScript.
(require [java-time :as java-time])
(-> (java-time/local-date 2020 10 02)
java-time/year
java-time/format)
Date/time arithmetic is also easy.
(java-time/time-between
(java-time/local-date 1990 10 02)
(java-time/local-date 2020 10 01)
:years)
The Clojure.Java-Time
namespace provides all the common date/time functions. However, the deep dive of this tutorial will be reserved for cljc.java-time
because it was built with platform interop in mind.
CLJ ↔ CLJS ↔ EDN
Henry Widd dives into his contributions to these libraries in Cross Platform DateTime Awesomeness at Clojure/north 2019. I'm going to group his two libraries together because they provide a way to seamlessly move time data from ClojureScript ↔ Clojure ↔ edn (or any other serialized transmission/storage).
cljc.java-time
Working with LocalDate
is simple. require
the namespace and parse a well-formed string. The result is a java.time.LocalDate
.
(require [cljc.java-time.local-date :as ld])
(def date-string "2015-02-12")
(ld/parse date-string)
Working with a LocalDate
is the same as it is in native Java. It's just a little easier to read.
(defn add-90-days [date]
(-> (ld/parse date)
(ld/plus-days 90)
str))
(add-90-days date-string)
Getting today's year is straight forward.
(ld/get-year (ld/now))
But getting today's day of the week requires a little more work. Whereas java.time.LocalDate
is an instance of Java's root object class, DayOfWeek
is an Enum
object: java.lang.Object
→ java.lang.Enum
→ java.time.DayOfWeek
.
ld/now
returns ajava.time.LocalDate
ld/get-day-of-week
returns ajava.time.DayOfWeek
(require [cljc.java-time.day-of-week :as dow])
(dow/to-string (ld/get-day-of-week (ld/now)))
(ld/get-day-of-week (ld/now))
time-literals
Literals
A literal is a form that returns itself when evaluated. For example, the s-expression (+ 3 4)
represents both -
A syntax tree when printed as a (list) that contains three atoms
+
,3
, and4
.A form when evaluated by Clojure. It includes a symbol for addition,
+
, and two numeric literals,3
and4
.
The results of evaluating two different forms:
3
⇒3
(the numeric literal evaluates to a numeric literal)(+ 3 4)
⇒7
(the s-expression evaluates to a numeric literal)
(str "Evaluate `3`: " 3 ", Evaluate `(+ 3 4)`: " (+ 3 4))
Literals go beyond numbers. They also include strings, characters, nil
, booleans, keywords, symbolic values (##Inf
(∞), ##-Inf
(-∞), and ##NaN
(Not a Number)), collections (lists, vectors, maps, and sets), and records (deftype
and defrecord
).
Tagged Literals
Clojure 1.4 introduced custom literals marked by a tag. For example, 1.4 shipped with built-in tagged literals for #uuid
and #instant
. Prefixing the literal "efea38cd-0db8-4f66-b3ab-c50b4c08d907"
with #uuid
generates a java.util.UUID
type:
uuid "efea38cd-0db8-4f66-b3ab-c50b4c08d907"
Clojure reads the literal string first, "efea38cd-0db8-4f66-b3ab-c50b4c08d907"
, and then invokes the tagged literal function on the string (or whatever literal happens to follow the tag).
In this example, #uuid
dispatches java.util.UUID/fromString
on "efea38cd-0db8-4f66-b3ab-c50b4c08d907".
The generic string is now a meaningful bit of data.
(= "efea38cd-0db8-4f66-b3ab-c50b4c08d907" (java.util.UUID/fromString "efea38cd-0db8-4f66-b3ab-c50b4c08d907"))
(= uuid "efea38cd-0db8-4f66-b3ab-c50b4c08d907" (java.util.UUID/fromString "efea38cd-0db8-4f66-b3ab-c50b4c08d907"))
#uuid
cannot prefix a string that does not qualify as a valid UUID.
(try
(java.util.UUID/fromString "not-a-uuid") ;; #uuid "not-a-uuid"
(catch IllegalArgumentException e (.getMessage e)))
Tagged literals are like a constructor for an object. But they are succinct and easily read by humans - making them a perfect fit for structured data. They are the "extensible" part of the extensible data notation (edn) specification.
Time Literals
Edn is a preferred format for serializing and deserializing data. Here's how we might store and send time and date information over the wire.
Tag a generic date string with #inst
to generate a java.util.Date
object:
(print (type inst "2020-05-11"))
inst "2020-05-11"
Store that information as a string. Later, if is read by a reader that recognizes the #inst
tag, it will automatically construct a java.util.Date
object. This happens automatically when reading a string using clojure.edn
:
(prn-str inst "2020-05-11") ; ⇒ "#inst "2020-05-11T00:00:00.000-00:00" "
(clojure.edn/read-string (prn-str inst "2020-05-11"))
It is now possible to operate on the date and time information - add days, find the difference between two dates, grab the year, etc....
(import (java.text SimpleDateFormat))
;; Grab the year
(->> (prn-str inst "2020-05-11")
(clojure.edn/read-string)
(.format (SimpleDateFormat. "yyyy")))
But there is a big problem! We don't want to work with java.util.Date
, otherwise known as Joda Time. This is where the time-literals
package comes in handy.
As a reminder, I want to operate on an object like this one so I have access to all the handy date and time functions supplied by cljc.java-time
:
(ld/now)
Here are some of the tagged literals supplied by time-literals
:
time/month "JUNE"
time/period "P1D"
time/date "2039-01-01"
time/date-time "2018-07-25T08:08:44.026"
time/zoned-date-time "2018-07-25T08:09:11.227+01:00[Europe/London]"
time/offset-date-time "2018-07-25T08:11:54.453+01:00"
time/instant "2018-07-25T07:10:05.861Z"
time/time "08:12:13.366"
time/duration "PT1S"
time/year "3030"
time/year-month "3030-01"
time/zone "Europe/London"
time/day-of-week "TUESDAY"
Printing the date to a string looks like this:
(require [time-literals.read-write :as time-read])
(def time-date-string (time-read/print-date "2015-12-11"))
time-date-string
Using the edn/read-string
function with the #time/date
tag is almost identical to #inst
. The only difference is the supplied reader: {:readers time-read-prn/tags}
. This is necessary because Clojure does not have any built-in #time/date
functionality.
(clojure.edn/read-string {:readers time-read/tags} time-date-string)
The tags make it easy to read a string, December 11, 2015, and add 90 days to generate the result March 10, 2016:
(require [cljc.java-time.format.date-time-formatter :as formatter])
(-> (clojure.edn/read-string {:readers time-read/tags} time-date-string)
(ld/plus-days 90)
(ld/format (formatter/of-pattern "MMMM dd, yyyy")))
If I want to get the year from a tagged literal string pulled off the wire or a file:
(->> "#time/date \"2011-01-01\""
(clojure.edn/read-string {:readers time-read/tags})
(ld/get-year))
Appendix
Joda Time
Generate a java.util.Date
.
(import (java.util Date))
(import (java.util Calendar))
(import (java.util GregorianCalendar))
(import (java.text SimpleDateFormat))
(Date.)
(= (.getTime (GregorianCalendar.)) (Date.))
(.parse (SimpleDateFormat. "yyyy-MM-dd") "2019-04-30")
(.getWeeksInWeekYear (GregorianCalendar.))
(-> (doto (GregorianCalendar/getInstance) (.setTime (Date.)))
(.get Calendar/WEEK_OF_YEAR))
(->> (Date.)
(.format (SimpleDateFormat. "yyyy-MM-dd")))
Java Calls and System Calls
A few notes on Java calls in Clojure.
dot "." is a special form for Java methods and attributes
double-dot ".." enables call chaining
(defn add-90-days [date]
(let [date-format java.time.format.DateTimeFormatter/ISO_LOCAL_DATE]
(.. (java.time.LocalDate/parse date date-format)
(plusDays 90)
(format date-format))))
(add-90-days "2015-02-13")
(doto (new java.util.HashMap) (.put "a" 1) (.put "b" 2))
(.. System (getProperties) (get "os.name"))
Deps
{:deps
{org.clojure/clojure {:mvn/version "1.10.0"}
org.clojure/tools.deps.alpha
{:git/url "https://github.com/clojure/tools.deps.alpha.git"
:sha "f6c080bd0049211021ea59e516d1785b08302515"}
compliment {:mvn/version "0.3.9"}
time-literals {:mvn/version "0.1.4"}
cljc.java-time {:mvn/version "0.1.11"}
clojure.java-time {:mvn/version "0.3.2"}}}