0.0.0-201704131332 • Published 7 years ago

bridje v0.0.0-201704131332

Weekly downloads
2
License
MIT
Repository
github
Last release
7 years ago
  • Bridje

Bridje is a statically typed LISP, drawing inspiration from both Clojure and Haskell. It currently runs on the Graal JVM, but is fundamentally a hosted language - the intention being to have it run on the JavaScript ecosystem too (eventually).

Its main features include:

  • Polymorphic records and variants with type-checked keys, similar to Clojure's 'spec' library
  • A monad-less 'effect system' to statically determine an expression's side causes and side effects.
  • Type-safe macros.

It is currently pre-alpha, and nowhere near finished. What follows below are ideas for how I imagine the language would work!

Bridje has a number of built-in forms, many similar to their Clojure counterparts. It also has a few additions, particularly regarding the record data structures, and its effect and typeclass systems.

** Basic forms

Primitives in Bridje are mostly the same as Clojure:

  • Strings: "foo" - encased in double-quotes.
  • Bools - ~true~ / ~false~
  • 'Int's: ~4~, ~9~ - 64 bits.
  • 'Float's: ~5.3~, ~10.5~ - IEEE double-precision.
  • 'BigInt's: ~105N~, ~253N~ - arbitrary precision
  • 'BigFloat's: ~153.25M~, ~256.68M~ - arbitrary precision

Function application works as in any other LISP - ~(foo param1 param2)~. Parameters are eagerly evaluated before calling the function.

'If' and 'do' expressions are unsurprising. In an 'if' statement, though, because Bridje is typed, the two branches must have an equivalent type.

#+BEGIN_SRC clojure (if (> x y) x y) #+END_SRC

Let and loop/recur behave as in Clojure. Like in Clojure, 'recur' must be called from the tail position, and no tail-call optimisation is performed elsewhere. If no ~loop~ block is present, the recur point is assumed to be the containing function.

#+BEGIN_SRC clojure (let x 4 y 5 (+ x y))

(loop x 5 y 0 (if (pos? x) (recur (dec x) (+ y x)) y)) #+END_SRC

** Naming values, defining functions

We define both values and functions using =def=. We can optionally specify the type of a value/function explicitly using =::=- it's not required, but it's checked, and useful as documentation.

#+BEGIN_SRC clojure (ns my-project.my-ns) ; more on this later

(:: x Int) (def x 4)

; functions have their name and parameters wrapped in parens

(:: (say-hi Str) Str) (def (say-hi s) (str "Hello, " s "!")) #+END_SRC

Values are all defined within a namespace, specified at the top of the file. More on this later, including how to refer to values declared in other namespaces.

** Collections

Vectors and sets in Bridje are homogenous, unlike Clojure's heterogeneous collections. They are immutable, and can be arbitrarily nested.

#+BEGIN_SRC clojure 1 4 3 #{"foo" "bar"} #+END_SRC

Bridje does have homogenous maps, but they do not have a specific syntax - they are constructed from sequences of tuples.

** Records In a similar manner to Clojure's 'spec', we define the type of a key, then any value of that key, throughout the program, is guaranteed to have the same type.

We define keys using =::=:

#+BEGIN_SRC clojure (:: :id Int) (:: :text Str) (:: :a-big-number BigInt)

(def foo {:text "Hello" :a-big-number 52N}) #+END_SRC

As in Clojure, we can destructure records, although the syntax is quite different. In particular, in the long-hand form, the destructuring matches the structure of the record itself. Here are some examples:

#+BEGIN_SRC clojure ;; long-hand - we bind the symbol 'the-id' to the value of ':id' in the record (let {(:id the-id)} {:id 4} ;; the-id = 4 )

;; if the bound symbol matches the key, we don't have to specify it twice - the ;; symbol suffices (commas are considered whitespace): (let {username, (:id user-id)} {:username "jarohen", :id 451} ;; username = "jarohen" ;; user-id = 451 )

;; we can bind the entire data structure by wrapping the symbol with (* ...) (let {username (* user)} {:username "jarohen", :id 451} ;; username = "jarohen" ;; user = {:username "jarohen", :id 451} ) #+END_SRC

Records can also be nested. Drawing inspiration from hints in Rich Hickey's 'Maybe Not' talk, we do not specify ahead of time what keys a record type contains, only that it is a record. When the record is used, the type system then decides which keys the user of the record requires at that particular site.

#+BEGIN_SRC clojure (:: :user-id Int) (:: :name Str) (:: :follower-count Int)

;; we specify that :user is a record, and give it some default keys ;; these can be overridden at each usage site. (:: :user {:user-id :name :follower-count})

;; the type of say-hi is (Fn {:name} Str) ;; - a function from a record containing a :name key to a string (def (say-hi user) (format "Hi, %s!" (:name user)))

;; we can also nest the destructuring. N.B. whereas Clojure's destructuring ;; syntax is 'inside-out', Bridje's more closely matches the structure of the ;; input data

(let {(:user {(:follower-count followers)})} {:user {:follower-count 4424}} ; followers = 4424 )

(let {(:user {follower-count})} {:user {:follower-count 4424}} ; follower-count = 4424 ) #+END_SRC

We can define type aliases for common sets of keys:

#+BEGIN_SRC clojure (:: BaseUser {:user-id :name})

(:: (say-hi BaseUser) Str) #+END_SRC

** Variants ('union'/'sum' types)

In addition to records, with a set of keys, Bridje also allows developers to declare 'variants' - a data structure that has /one/ of a possible set of keys. Variant keys are distinguished from record keys by using an initial capital letter:

#+BEGIN_SRC clojure (:: :Int Int) (:: :String Str) (:: :Neither) ; variants don't need a value, necessarily; they can also have more than one.

;; we then construct instances of these variants using the key as a constructor: ;; this is of type [(+ :Int :String :Neither)] - a vector whose elements either have an ;; :Int key, a :String key, or the value :Neither (def ints-and-strings (:Int 4) :Neither (:String "hello"))

;; we can deconstruct variants using a case expression (destructuring if need be). ;; in a similar vein to the if expression, all of the possible outputs of a ;; case expression must have the same type.

(case (first ints-and-strings) (:Int an-int) (+ an-int 2) (:String a-string) (count a-string) :Neither 0)

;; again, we can define type aliases for common variants: (:: IntOrString (+ :Int :String :Neither)) #+END_SRC

** Macros

Bridje macros aim to operate as similar to Clojure's macros as possible - however, without a heterogeneous list type, we need another way of expressing and manipulating forms.

Instead, we use variants - a macro is then a function that accepts a number of Forms, and returns a Form.

#+BEGIN_SRC clojure (:: :StringForm Str) (:: :IntForm Int) (:: :ListForm Form) (:: :VectorForm Form) ;; ...

(:: Form (+ :StringForm :IntForm :ListForm :VectorForm ...))

(defmacro (my-first-macro form) (case form (:StringForm str) (:StringForm "sneaky!") form))

;; fortunately, syntax-quoting/unquoting translates into Form-generating code as ;; you'd expect, so, most of the time, Bridje macros will have similar implementations.

(defmacro (if-not pred then else) `(if ~pred ~else ~then)) #+END_SRC

** Namespaces

Namespaces are collections of symbols and keys. In the namespace declaration (which must be the first declaration in the file) we can specify which symbols and keys we'd like to refer to from other namespaces:

#+BEGIN_SRC clojure ;; my-project/users.brj

(ns my-project.users)

(:: :user-id Int) (:: :name Str)

(:: BaseUser {:user-id :name})

(def (say-hi {name}) (format "Hi, %s!" name))

;; my-project/another-ns.brj

(ns my-project.another-ns {:aliases {users my-project.users} :refers {my-project.users #{:user-id say-hi}}})

;; we can now refer to members of the 'users' namespace using either their ;; alias, or, for the symbols we referred, directly: (:user-id user) (say-hi {:name "James"})

(:users/user-id user) (users/say-hi {:users/name "James"})

(:: (save-user! users/BaseUser) Void) #+END_SRC

Namespaces are loaded as a whole unit - you cannot just load a single =def= in Bridje. This is partly to ensure type consistency within the namespace - we don't want re-declaring a definition to invalidate the type guarantees. You can, however, evaluate other forms (that don't change the contents of a namespace) individually at the REPL.

** Effects

One of Bridje's main features is its effect system - a way of knowing at compile-time what side causes/side effects a function depends on.

We use the example of a simple logging system, where we want to log to stdout. In Bridje, we declare an effectful function by wrapping the declaration with =(! ...)=. We can then provide a default implementation, which may in turn call lower-level effects.

#+BEGIN_SRC clojure (:: (! (print! Str)) Void) (:: (! (read-line!)) Str)

(def (print! s) ;; interop )

(def (read-line!) ;; interop )

(def (println! s) (print! (str s "\n")))

(:: :Debug) (:: :Info) (:: :Warn) (:: :Error)

(:: Level (+ :Debug :Info :Warn :Error))

(:: (! (log! Level Str)) Void)

(def (log! level s) (print! (format "Log %s: %s" (pr-str level) s)))

(def (my-fn x y) (log! :Debug (format "Got x: %d, y: %d" x y)) (+ x y)) #+END_SRC

Effects propagate through the call stack - in this case, the ~println!~ function is determined to use the ~print!~ effect. The ~my-fn~ function is determined to use the ~log!~ effect, but not ~print!~ (because default implementations can be overridden).

We can provide/override implementations of effects using the ~with-fx~ expression. This defines the behaviour of the effect in the /lexical/ scope of the block.

#+BEGIN_SRC clojure (with-fx (def (print! s) ...)

(log! :Info "Hello!"))

#+END_SRC

=with-fx= introduces a non-trivial overhead to swap out the implementation (in order to make the default implementations faster) - it is advisable not to use this in performance-critical code.

There is one 'base' effect, =IO=, which interacts with the outside world. This is built-in and cannot be explicitly handled.

*** 'Internal' mutable state #+BEGIN_QUOTE If a pure function mutates some local data in order to produce an immutable return value, is that ok?

--- https://clojure.org/reference/transients #+END_QUOTE

While immutable code is generally 'fast enough' for most use cases, sometimes, in performance critical code, it's necessary to fall back to mutable data structures. Given that callers shouldn't be able to tell the difference between pure code and otherwise pure code that happens to use mutability internally for performance, we don't (currently) include this as part of Bridje's effect system - in this case, it's up to the developer to reason about their code and ensure it's safe.

At its lowest level, the mutable primitives that Bridje exposes are mutable references (a =mut=, pronounced 'mute') and 'transient' collections, similar to Clojure.

** Java Interop We can import functions from Java as if they are Bridje functions - we just need to declare their types.

#+BEGIN_SRC clojure (ns my-ns {:aliases {RT (java java.lang.Runtime)}})

;; we can then use those functions using the RT alias

(:: (RT/getRuntime) RT) (:: (RT/freeMemory RT) Int)

(RT/freeMemory (RT/getRuntime)) #+END_SRC

** Polymorphism

Polymorphism appears in Bridje in two forms - polymorphic keys and polymorphic functions.

Polymorphic keys are declared by applying keys to type variables. For example, the core library declares a polymorphic =:Ok= variant which can contain a value of any type:

#+BEGIN_SRC clojure (:: (:Ok a) a) #+END_SRC

This declaration is saying that the =:Ok= variant has a type parameter called =a=, and that its type is that same type =a= - i.e. it has no constraints. We then use the =:Ok= variant as we would any other variant - introducing it using =(:Ok 42)= (which has type =(+ (:Ok Int))=) and eliminating it with =case=:

#+BEGIN_SRC clojure (:: (:Ok a) a)

(case (:Ok 42) (:Ok int) (even? int) false) #+END_SRC

Polymorphic functions are declared in a similar way - prefixing their names with =.=. This is how to declare a polymorphic =count= function, which takes any type and returns an =Int=:

#+BEGIN_SRC clojure (:: (. a) (count a) Int) #+END_SRC

We can then define how =count= behaves for specific types using that same syntax in a =def= form. In this case, let's define our own list structure, and define how to count it:

#+BEGIN_SRC clojure (:: (. a) (count a) Int)

(:: (:Cons a) a (List a)) (:: :Nil) (:: (List a) (+ (:Cons a) :Nil))

(def (. (List a)) (count list) (case list (:Cons el rest) (+ 1 (count rest)) :Nil 0)) #+END_SRC

We can also express 'higher-kinded' functionality, like how to map a function over a structure.

#+BEGIN_SRC clojure (:: (. f) (fmap (f a) (Fn a b)) (f b)) #+END_SRC

We can then define how to map a function over our list type:

#+BEGIN_SRC clojure (def (. List) (fmap list f) (case list (:Cons el rest) (:Cons (f el) (fmap rest f)) :Nil :Nil)) #+END_SRC

** Error handling

There are two types of error in Bridje - we make a distinction between errors that the immediate caller is expected to handle, and errors that they aren't.

Errors that the caller is expected to handle can be wrapped in user-defined variant types. If you have a function that has a success case and a number of error cases, you can declare each case as a variant key, and then eliminate the variants with a =case= expression as you would with any other variant. You can use the =:Ok= variant from the core library for the happy cases, but you'll likely want something more descriptive for your errors.

#+BEGIN_SRC clojure (:: :InvalidInput) ; basic variant - can contain a value to return more details about the error

;; returns (+ (:Ok res-type) :InvalidInput) (def (might-error arg ...) (if (input-valid? arg) :InvalidInput (:Ok (process-input arg))))

;; calling might-error (case (might-error my-arg) (:Ok res) ... :InvalidInput ...) #+END_SRC

Often, there might be many steps in a process, each of which could error in a variety of ways. It'd get pretty boring to extract the =:Ok= value out each time if you're just going to pass the errors through. So, on the right hand side of a =let= binding, we can wrap the expression in =try=. If the expression returns an =:Ok= variant, it's unwrapped and the =let= expression continues; if not, the =let= expression returns the error.

#+BEGIN_SRC clojure (case (maybe-error input) (:Ok parsed-input) (case (try-something-else parsed-input) (:Ok res) (use-result res) (:AnotherError err) (:AnotherError err))

(:AnError err) (:AnError err)

;; becomes

(let parsed-input (try (maybe-error input)) res (try (try-something-else parsed-input)) (use-result res))

;; try is also supported within ->:

(-> (try (maybe-error input)) (try try-something-else) use-result) #+END_SRC

Errors that the caller isn't expected to handle are thrown with the =throw= built-in - again, any variant is supported. These errors can be handled, likely at the boundary of your system, by using =catch=:

#+BEGIN_SRC clojure (def (throwing config-str) (case (parse-config config-str) (:Ok config) config :InvalidConfig (throw :InvalidConfig)))

;; we could also use 'assume' in this case - a core function that returns the ;; contained value in :Ok cases, but throws otherwise:

(def (assuming config-str) (assume (parse-config config-str)))

;; catching that error at the boundary

(def (start-system ...) (case (catch (...)) (:Ok system) ... e (log! :Error "The system failed to start."))) #+END_SRC

(n.b. not so sure about the =finally= syntax)

As in other languages, we want to ensure that no matter what happens, our resources get cleaned up. For this, we use =finally= - a block of code that's evaluated whether the code within succeeds or fails. In Bridje, this is achieved with a standalone expression in the middle of a =let= binding:

#+BEGIN_SRC clojure (def (cleaning-up ...) (let [resource (open-resource! ...) (finally (close-resource! resource))

      ...]
  ...))

#+END_SRC

We can be sure that the resource is closed after the =let= block finishes, regardless of whether it yields a result, an error, or throws.

  • LICENCE

Licence tbc. For now, all rights reserved. Feel free to have a browse, though.