-
working REPL
-
working fexprs
-
still need to clean up the eval and apply functions. The environment is inherently mutating, and we need to reflect that (or pull some rather fancy footwork with respect to mutual recursion)
-
working REPL
-
working fexprs
-
working partials
-
still need to write a separate reader (we want to explore some reader macro territory, which means we'll be dealing with things that aren't standard LISP forms)
-
half a reader
-
still need to write everything
Don't.
Not yet, at any rate, I've barely got the reader written. If you absolutely must:
- Clone this repo
- Open
Experimentascript.html
in a browser (note that you still need Haskell and Haste to actually edit it, but you can run it without those)
- Install Racket
- Run
racket -t experimentalisp.rkt -m
- Install Haskell
- Run
ghc Experimentalisp.hs
- Run the resulting binary (which should be named
Experimentalisp
)
- Run
ghci Experimentalisp.hs
- Evaluate the
main
function in the resulting Haskell REPL
experimentaLISP
is an experimental lisp dialect. It's meant to help a few curious individuals explore the implications and interactions of certain language features that may or may not be in favor with the mainstream Lisp communities. At the moment, these include fexprs, first-class environments, first class reader macros, types, purity, various approaches to polymorphic functions, and some ideas about modules.
experimentaLISP
is not, and is not trying to be
Scheme uses hygienic macros, doesn't concern itself overly with reader macros and doesn't allow full access to environments. It also has 7 RSes worth of baggage/good sense that I'm not interested in re-implementing. If you want to use a Scheme or Scheme-like language, I suggest Chicken or Racket.
Common Lisp uses a compile-time macro system, doesn't concern itself overly with reader macros, doesn't allow full access to environments and doesn't particularly care about avoiding side-effects. It also has some 30 years worth of baggage/good sense that I'm not interested in re-implementing. If you want to use a Common Lisp, I recommend SBCL or CCL.
By its nature, a project like this is going to be highly unstable and prone to explosions. Use it to sate your curiosity about certain language features, or perhaps as a starting point for your own experiments, not as the foundation for large software projects.
experimentaLISP
is not portable, except to the extent that our substrate languages are portable. Also, while there is a feature list comparison up top, there may be variations between implementations. We're using a multi-language development approach with an aim to test out implementation details from different perspectives, rather than to have portable infrastructure. If you're looking for a Lisp dialect that you can use in a wide variety of substrates and platforms, look into Shen.
- We probably want primitive fexprs (that would let us add some additional stuff from inside the language)
- An alternative to
&rest
arguments is just makinglist
a special form. Trying it out. - We should probably separate
fexpr
s into two stages; the expansion and the evaluation of the expansion. If done properly, we can then provideexpand
in the guest language for a pretty cheap macroexpander. the-env
; returns the current environment as an expression- That environment must have the operations
bind
,bound?
,lookup
andextend
implemented.lookup
must take an environment and a symbol, and return the value to which said symbol is bound in the given environment.bound?
must take an environment and a symbol, and returntrue
if that symbol is bound in that environmentbind
must take an environment and a symbol, and return a new environment in which that symbol is bound. It should not mutate the existing environment.extend
must take an environment and add a frame to it
- We need some kind of port abstraction. In three flavours:
in
,out
andin/out
. That'll let us deal with a whole bunch of stuff inside the language, including files, sockets, and standard in/out.
Example:
(open-file "test.txt")
> <port IN file:test.txt> ;; should get de-allocated here; no reference to it exists
(def p (listen localhost 4040))
> p
p
> <port IN socket-server:4040>
(def client (accept p))
> client
client
> <port IN/OUT socket: [local]/[remote]>
(read-char! client)
> \H
(read-char! (open-file "test.txt")) ;; same deal as above
> \t
(def f (open-file "test.txt"))
> f
(read-char! f)
> \t
(read-char! f)
> \e
(close! f)
> NIL
(read-char! f)
> <ERROR: read-from-closed-port (f . <port IN file:test.txt>)>
- Linked lists and vectors seem like the two basics you need. If you have those, you can build Begwell-style compounds out of them, but not the other way around.
- Going against mutation seems like a losing battle if you want a living system. There are like a hundred different little ways environments and basic forms depend on mutation under the hood in completely non-obvious ways. The SML approach is beginning to look more and more sound (that is, the approach of not segregating IO functions from others), even discounting the exception stuff
- Do we still want single-assignment? That, at least, looks like something we can work with...
- We still need namespaces. Pretty much entirely for human readers; we need to be able to group a bunch of functions by subject (or something equally nebulous), but refer to them individually. As mentioned, these namespaces can probably be implemented as environment frames.
- Think about procedure types. Specifically, think about cons/car/cdr (you clearly need type variables here, and you need to understand the concept of container types as it applies)
- Sane interface seems to be
type
/type-of
/type-check
Example:
(type (cons Number Number))
> (Number . Number)
(type-of (cons 3 4))
> (Number . Number)
(type-check (cons 3 4) (type (cons Number Number)))
> true
(type-check (cons 3 4) (type (-> Number Number Number)))
> false
(type-check + (type (-> Number Number Number)))
> true
(type-of +)
> ((-> Number Number Number))
(type-of (list 1 2 3 4 5 6 7 8))
> (Number)
(type-of "test")
> String ;; maybe `(Char)`?
- Each typeclass is an environment mapping to the different types that implement it to the implementation of the specific methods entailed by that typeclass
- Assuming first-class environments, this'd be fairly simple to implement. We would also need a map of methods to their source typeclass.
- It looks like the generic function approach might be easier to implement, and it would have one fewer lookup level (each function would directly dispatch on arguments, rather than pointing to a typeclass entry)
- Side-note: it looks like some of the problems that typeclass implementations are running into will be solved, approximately, by moving closer to the generic-function approach
- If we ditch partials and go the multiple arity route, it suddenly starts looking sensible to just allow the same name for functions of different type and arity. That implicitly gives you more flexible generic functions, as long as you can do the more complicated dispatch. That dispatch seems like it'll get difficult. Especially for overlapping functions. How do you sort them? How do you even find all of the relevant ones from a particular environment perspective? It sounds like the sanest approach is to throw your hands up and say "screw it, we're going from
the-env
to the global environment and picking the first matching one on the way". Argleblargh! I need to think harder about this.
- An environment is a set of symbol bindings AND a set of handler bindings. If an error happens, we match in the handler bindings (the default, top-level environment should have a default handler that drops the user into a prompt)
- Errors should probably get their own type in the LispVal declaration (as well as their own type in the type system)
Example:
(error test-error 5)
> <ERROR: test-error 5>
(raise (error test-error 5))
> ;; top-level handler effect
(handle test-error (fn (err) 'caught-error))
> NIL
(raise (error test-error 5))
> caught-error
- Seemslike they could basically be environments
- except for the parameterizing thing, for which they'd have to be functions which return environments
- and except for the fact that they should be able to implicitly return values interned in them
- should they? a
get
doesn't seem like a bad thing. If the length is concerning you,.
is also an option - there is the difference that we want modules to protect certain values. What we could do is model environments as modules that
provide
all of their values
- should they? a
- You want to support
- use foo (include an environment in the image; this is basically just an environment merge)
- from foo import bar (put a specific symbol from an environment into the current one;
(def bar (lookup bar foo))
) - from foo import bar as baz (put a specific symbol from an environment into the current one;
(def baz (lookup bar foo))
) - parameterizing environments
- That may actually be backwards too; it seems like you could get the same effect by saying "here's my code, run it in this environment". Or maybe "...run with this qualified environment". Worth exploring; this seems like one of the big wins of having first-class envs.
- The environment approach may be a bit too porous. What kind of use cases do you really have for this stuff? Lets start with the obvious ones:
- If you have a piece of code from some untrsted external source, you want to be able to say something like "Run this in an environment where the IO procedures are switched out with nerfed ones".
- If you have a piece of code that implements something with the data structure
foo
, you want to be able to say "Run this, but replacefoo
with the equivalently-interactingbar
(for example, maybe switch out the particular tree-representation in aSet
implementation)
- By default, a file that doesn't start with a module form will implicitly be treated as though all its contents are in an unparametereized module that provides everything defined in it
- Modules should have multiple option lists you can pass before starting to define symbols. Specifically,
provide
- If not given, the module acts as though it
provide
s all defined symbols - If given, it instead only provides the specified symbols
- Maybe an extra option that lets you specify symbols you're NOT exporting (if there's more exports than imports)?
- If not given, the module acts as though it
- values that need to be given to this module at initialization time (for instance, you might want the user to provide some default values or concrete data structure implementations)
require
- modules not
ask
ed that need to be present in order for this to work - The below example states that
math
depends on nothing (NIL
). It will actually depend on something likebase
, because it needs to have access to the basic language primitivesfn
,+
/-
//
/*
,fexpr
anddef
. - does this need to be user-specified? Seems like we could walk the tree of a modules' symbols and figure it out
- modules not
Example:
(module (provide add sub div mul)
add (fn (a b) (+ a b))
sub (fn (a b) (- a b))
div (fn (a b) (/ a b))
mul (fn (a b) (* a b))
foo (fn (a) (write a)))
> <module>
(def math
(module (provide add sub div mul)
add (fn (a b) (+ a b))
sub (fn (a b) (- a b))
div (fn (a b) (/ a b))
mul (fn (a b) (* a b))
foo (fn (a) (write a))))
> math
NIL
(math add)
> <fn (a b)>
((math add) 3 4)
> 7
(def plus (math add))
> NIL
(plus 3 4)
> 7
(provides math)
> (add sub div mul)
(math foo)
> <ERROR: accessing-private-symbol (math foo)>
(math bar)
> <ERROR: accessing-nonexistent-symbol (math bar)>
(requires math)
> NIL
(asks math)
> NIL
(def trie
(module (table)
empty (table empty)
insert (fn (trie word)
;; do trie insertion using table here
)
member? (fn (trie word)
;; do trie lookup using table here
)
lookup (fn (trie word)
;; traverse the given trie here
)
remove (fn (trie word)
;; do trie removal using table here
)))
> trie
trie
> <fn (table)>
(trie hash-table)
> <module>
(trie array-map)
> <module>
(trie avl-tree)
> <module>
Counterpoint: Fuck Modules
- Forget modules entirely. Allow functions of different arity and type signature, but with the same name to exist, keep track of dependencies on a per-function level.
- Does this buy you anything composition-wise? I could see
start
andend
functions getting weird, so you still probably want some kind of namespacing. - Installation/deployment might get a bit easier. Custom compilation might get a bit easier. Give it some though.
- Still need namespaces, probably qualified by username or something, but no parameterization (we're doing module delimitation on a function level)
Pie in the sky example (because, as you'll see, this is hard):
> (fold + (list 2 3 4 5 6 7 8 9 10))
;; checking for fold/2 :: (-> (-> Number Number Number) (Number) a)
;; found one candidate: inaimathi.core.fold/2 :: (-> (-> a b a) (a) b)
;; installing...
54
> (fold + '(a b c))
;; checking for +/2 :: (-> Symbol a b)
;; found one candidate: dxnn.sicp.symbolic.+/2 :: (-> a b a)
;; installing...
((a + b) + c)
> (start 4343)
;; checking for start/1 :: (-> Number a)
;; found two candidates
;; 1. inaimathi.house.start/1 :: (-> Number NIL)
;; 2. inaimathi.ports.hunchentoot.start/1 :: (-> Number NIL)
;; Please select 1 or 2
1
;; found 48 dependencies for inaimathi.house.start/1
;; installing...................................................
Listening on 4343...
C-c C-c
>
;;; hello-world.exp
(def main () (print "Hello world!))
In repl:
(load (hello-world))
> <fn ()> ;; the main function of the loaded module
((load (hello-world)))
>> Hello world!
> NIL
hello-world
> <module>
(load-as test (hello-world))
> <fn ()>
test
> <module>
(load (nonexistent))
> <ERROR: cant-find-module (nonexistent)>
In command line:
$ exp hello-world.exp
Hello world!
$
The idea is that, since its first non-comment form is not a module
term, hello-world.exp
implicitly defines the hello-world
module, which requires no configuration options and provide
s all defined forms.