projects.flowsnake.org
liquid

(NOTE: This document describes the Python prototype.)

The Liquid language

Liquid is a dialect of Lisp. Besides Scheme and Common Lisp, languages that influenced its design are Python, Ruby and Clojure.

The language is currently in prototype stage; right now only an implementation in Python exists. This description is based on that prototype.

Disclaimer: Pretty much everything described here may change in future versions. At this point, nothing is set in stone. I'm just trying to build a somewhat decent language, and learning new things along the way. :-)

(last update: 2009-07-20)

Lisp/Scheme heritage, and differences

Liquid is much like Scheme. It is lexically scoped, has closures (of course), tail recursion, and will eventually have continuations. (This is currently not in the prototype, but will be once the dollop evaluator has been backported.) There's a REPL, symbols, and lists based on cons cells.

However, it does not have macros. Instead, functions can be used for this purpose (see below).

Atomic types

Right now, the Liquid prototype has:

There is no nil. There is an "unspecified" value, but this might not make it into the next implementation.

Lists

Lists are built of cons cells, much like Scheme (and most other Lisp variants). Instead of cons, car and cdr, we use the names pair, head, and tail. (Unfortunately this means that we cannot use names like caddr (etc).)

The second argument to pair must be a list (either a pair or the empty list), so dotted pairs like in Scheme don't exist.

Functions

Functions look just like Scheme. For example, this is a valid function definition:

(define (f x)
  (+ (* x 2) 1))
  
(f 3)
=> 7

(define f (lambda (x) (+ (* x 2) 1)))
(f 3)
=> 7

No surprises there. However, functions also support two other constructs: rest arguments and keyword arguments.

Functions: rest arguments

A formal argument name that starts with the character &, is considered a rest argument. It may appear only once in an argument list, after the regular arguments but before keywords. The function below would be written (f x . rest) in Scheme.

>>> (define (f x &rest)
...   (list x &rest))
#<lambda (x &rest)>

>>> (f 1 2 3)
(1 (2 3))

Functions: keyword arguments

Keywords are symbols starting with a colon :, e.g. :foo. They may appear at the end of an argument list:

>>> (define (g :foo :bar)
...   (list foo bar))
#<lambda (:foo :bar)>

>>> (g :foo 4)
(4 #f)

>>> (g)
(#f #f)

>>> (g :bar)
(#f #t)

Functions: delayed evaluation

In addition to this, functions also support delayed evaluation. If an argument name starts with the character * (e.g. *foo), then the expression in the function call matching that argument, will be delayed. This works much like Scheme's delay function, but at the function call level.

As an example, the following function emulates the if special form, by taking two delayed expressions, and evaluating the appropriate one based on whether the condition is true or not:

>>> (define (my-if cond *a *b)
...   (if cond (force *a) (force *b)))
#<lambda (cond *a *b)>

>>> (my-if #t (+ 1 2) bogus)   ;; 'bogus' is undefined
3

Delayed expressions are really just lambdas, and as such are bound to an environment. Liquid has introspective features that let us extract the lambda's body, its environment, etc. This allows us to define all kinds of new syntactic constructs in Liquid itself, like cond, case, or, and, etc, without needing macros. Instead, we can take unevaluated expressions, possibly take them apart, and evaluate the parts we need on demand (or use them for other purposes). For example, the current version of cond in the standard library looks like this:

(define (cond &*body)
  (let ((body (delayed-expr &*body)))
    (if (empty? body)
        #f
        (let# (cond1 clause) (head body) ;; destructuring-bind, sort of
          (if (eval-in cond1 &*body)
              (eval-in clause &*body)
              (lazy-apply cond (tail body) &*body))))))

(where delayed-expr extracts a lambda's body without evaluating it; eval-in evaluates an expression in an environment; and lazy-apply is like apply but for functions that have delayed evaluation. oh, and let# is a destructuring form.)

First-class environments

Functions like lambda-environment extract a function's associated environment. This is possible because environments (namespaces) are first-class objects. They can be passed around, manipulated, etc.

Modules

Liquid has modules, inspired by Python (as opposed to Scheme-based module systems). Files can be imported as modules, which are first-class objects. Lookup can be done using a function (e.g. (module-lookup foo 'bar), but syntactic sugar is provided in the form of foo:bar.

;; file: combinators.lq
(define (I x) x)

;; import the module...
(import combinators)
(combinators:I 12)
=> 12

Metadata

All Liquid objects can be associated with metadata. Some names have special meaning to the system (or to certain functions), others can be freely defined for whatever purposes.

(define (h x y)
  (:doc "some docstring")
  (:signature (list number? number?))
  (+ x y))
  
(metadata-keys h)
=> (doc signature)

(While the metadata concept is here to stay, there are currently several unresolved issues, like what happens to metadata when an object is copied, etc.)

Generic functions (sort of)

Liquid does not support multimethods or generic functions out of the box. In general, users are encouraged to write specific functions; not a generic function length for everything that has a length or size, but string-length, vector-length, dict-length, etc.

However, this isn't always a good choice. Let's say we have a repr function that returns a string representation of an object -- any object. Or a serialize function for persistence. Not only should such functions accept all appropriate built-in objects, it's also useful if user-defined types can be added, so we can have custom representation strings, serialization, etc.

In such cases, metadata comes in handy. A repr function may look for an object's repr metadata key, and if set to a function, call that to get the custom representation string; otherwise, it would use the default (built-in) for the given type. (Compare e.g. Python: len(obj) works when an object has a __len__ method.)

Using this construct, custom types can also use the foo:bar syntactic sugar, as it translates to (getattr foo 'bar), and the getattr function looks for a getattr metadata key. (Useful for structs and object systems and the like, all of which can be written in pure Liquid.)

Python bridge

Since this prototype is written in Python, it made sense to include an interface to the Python language. This will change once an implementation is written in a different language, but for now, Liquid supports evaluation of Python expressions (as strings), method calls, importing of Python modules, etc.

;; md5.lq
;; Proof of concept to demonstrate the Python bridge.

(define (md5 s)
  (define hashlib (py-import "hashlib"))   ;; a module
  (define py-md5 (py-get hashlib "md5"))   ;; a function (FAIAP)
  (define hashobj (py-call py-md5 s))      ;; hashobj = md5(s)
  (py-send hashobj "hexdigest"))

;; elsewhere...
(import md5)
(md5:md5 "hello")
=> "5d41402abc4b2a76b9719d911017c592"

(Obviously, these functions would benefit from more syntactic sugar... like the foo:bar lookup. This is a work in progress.)

Other features