Show HN: Risp – Lisp in Rust
m.stopa.io
m.stopa.io
The big question when implementing a runtime for a Lisp-like language in Rust is how the interpreter will interact with the GC. It looks like this project has avoided most of the complexity by not implementing actual lists, and allowing multiple references to the same value to exist only in restricted circumstances, hewing close to Rust's ownership model rather than a typical Lisp's. I wonder, is the plan to stay on this side of the space, as a lightweight language with potentially smooth FFI interoperation with other low-level/Rust code? Or to rewrite almost everything to support the high-level semantics of list-processing languages, with their emphasis on shared structure?
For now, this was more of a toy project. If you have an interest in pushing either of these directions, would love to talk!
This means that RFI code can treat GCed values as having a `'static` lifetime. One caveat is that RFI code cannot capture values except for a few special runtime functions. This works naturally with the language’s pure functional design, however.
As far as data structures go its impossible to support performant Lisp code by implementing lists as native vectors; the guarantees and idioms are too different. My runtime [2] provides Rust bindings for data types that e.g. allow you to create an `Iterator` from a list or construct a list from one. That should allow transparent interoperability with idiomatic Rust patterns and data structures.
I personally hope this is the case; I feel like Lisps in that space are scarce. Strange interactions between the GC and "alien" functions are one of the things that make FFI painful even when using a Lisp with otherwise-thorough FFI support (like Chicken Scheme or most Common Lisps).
This mirrors my experience learning Rust while working on the C2Rust translator (HN discussion https://news.ycombinator.com/item?id=17381946). Great read!
Note: updated the essay and left a thanks to you :)
I tried to navigate back to HN using the back button, however it was inoperable. I found that passing every section heading on this article adds a line in your browsing history. Please don't mess with my browser history while I'm just scrolling. If I click on something that's fine, but not while I'm scrolling. I already have a history of where I am in your article, it's called the SCROLLBAR!
re: scrollbar -- oi -- definitely did not intend that. Will look into this (I am hosted medium, so don't have much ability to move things around, but will see what I can do)
And then maybe offer a version of it with a nice, normal, appropriate name, but charge money for that version.
It even comes with more "enterprisey" class-name aliases, if you need them.
[edit] Which apparently does exist: https://github.com/isamert/scheme.rs
I recently started working on something similar [0] in Go.
You're project looks great -- you even got channels! Can't wait to go deeper.
g-fu adds task isolation though, more along the lines of Erlang than Go.
Remixing Lisp is one of the most interesting things you can do in software as far as I'm concerned.
#[derive(Debug)]
enum RispErr {
Reason(String),
}
One of the beauties of Rust's type system is that you can combine meaningful error types with the From trait to build zero-effort error handling. When you turn every error into a String, you lose information that future programmers (including yourself!) will be glad to have. I know it can be a little bit painful to get used to, but once you do, meaningful error handling will be at your fingertips.To be helpful to readers of this comment and not just critical of the author, I recommend you don't overthink your error types when you're first starting to write your program. Think of the contents of the enum like a "scratchpad" where you list every failure mode you've encountered so far. While you're still developing, you can greedily match on every variant in your match statements, and it's fine. As you do this, you'll begin to notice themes that will inform the eventual refactor. Best of all, once you do refactor, you won't have to re-understand your code to tease out the various kinds of failures - you'll already have a list of them!
This sounds quite cool -- if you have the time, would you mind giving me a quick example? Want to make sure I have a full understanding of what you mean.
For example, if your errors are:
enum RispError {
/// Syntax error returning line number and char
SyntaxErr(u32, u32),
/// Parens not balanced; contains number of parens needed
UnbalancedParens(usize),
}
You can impl ToString straight away: impl ToString for RispErr {
fn to_string(&self) -> String {
match self {
RispErr::SyntaxErr(l,c) => format!("syntax error at line {}, col {}", l, c),
RispErr::UnbalancedParens(n) => format!("use your % key more! add {} more paren", n),
}
}
}
Then, when handling the error, you can match do_thing() {
Ok(v) => { ... },
Err(e) => { // TODO handle error
println!(e)
}
}
where do_thing's signature looks like fn do_thing(...) -> Result<T, RispErr>;
It takes the same amount of code, but all your error messages are in the same place, you haven't lost information about them, and refactoring to handle them becomes super easy.(n.b. I dashed this comment off without actually compiling the above, so please forgive any dumb errors =] )
[1] https://doc.rust-lang.org/std/string/trait.ToString.html
I gotta get to sleep, but will either update the post or add a note in the morning.
And of course, will do this in all future rust projects :D
http://www.lispworks.com/documentation/HyperSpec/Body/f_cerr...
http://www.lispworks.com/documentation/HyperSpec/Body/m_rst_...
See here: https://github.com/omarabid/rust-starter/blob/master/src/mai...
(Or maybe it's just a generic irony ... I can't tell yet.)
In the cases where I do want to distinguish between error cases, I'd still rather wait until I have the code consuming those error enums to help guide what errors are meaningfully different - evidenced by needing different error handling paths - instead of making every caller decide which of the dozens or hundreds of edge cases they want to be handled which way(s).
There are cases where your approach of distinguishing cases up front would be better - as an example, if you're limited in your ability to refactor due to the interface being part of a public API with semver restrictions to your refactoring - but there's plenty of cases where the author's approach is the correct one, where you should be cringing if you don't see things kept as simple as RispErr.
Arguments to values in Risp:
fn env_for_lambda<'a>(
params: Rc<RispExp>,
arg_forms: &[RispExp],
outer_env: &'a mut RispEnv,
) -> Result<RispEnv<'a>, RispErr> {
let ks = parse_list_of_symbol_strings(params)?;
if ks.len() != arg_forms.len() {
return Err(
RispErr::Reason(
format!("expected {} arguments, got {}", ks.len(), arg_forms.len())
)
);
}
let vs = eval_forms(arg_forms, outer_env)?;
let mut data: HashMap<String, RispExp> = HashMap::new();
for (k, v) in ks.iter().zip(vs.iter()) {
data.insert(k.clone(), v.clone());
}
Ok(
RispEnv {
data,
outer: Some(outer_env),
}
)
}
Arguments to values in TXR Lisp (interpreter), written in C: static void do_eval_args(val form, val env, val ctx,
val (*lookup)(val env, val sym),
struct args *args)
{
for (; form; form = cdr(form))
args_add(args, do_eval(car(form), env, ctx, lookup));
} C function Rust analog
args_add data.insert
do_eval eval_forms
Rather, the Rust function actually off-loads the evaluation work to another function, eval_forms. I should be comparing that one to do_eval_args: fn eval_forms(arg_forms: &[RispExp], env: &mut RispEnv) -> Result<Vec<RispExp>, RispErr> {
arg_forms
.iter()
.map(|x| eval(x, env))
.collect::<Result<Vec<RispExp>, RispErr>>()
}
That's a lot smaller, but still very noisy. My eyes bleed! fn eval_forms(arg_forms: &[RispExp], env: &mut RispEnv) -> Result<Vec<RispExp>, RispErr>> {
arg_forms.iter().map(|x| eval(x, env)).collect()
}
Or, if you don't like return value polymorphism, then: fn eval_forms(arg_forms: &[RispExp], env: &mut RispEnv) -> Result<Vec<RispExp>, RispErr>> {
let mut exps = vec![];
for x in arg_forms {
exps.push(eval(x, env)?);
}
Ok(exps)
}
It looks no more noisy than your C code, and I can't actually tell whether your
C code is doing error handling. Is it? If not, try adding it. Now which is
noisier?Not sure what error handling you have in mind, but it's robust. We can interactively call it from gdb with some garbage values:
(gdb) r
Starting program: /home/kaz/txr/txr-dbg
This is the TXR Lisp interactive listener of TXR 215.
Quit with :quit or Ctrl-D on empty line. Ctrl-X ? for cheatsheet.
1> (raise 5)
Program received signal SIGTRAP, Trace/breakpoint trap.
0x00132416 in __kernel_vsyscall ()
(gdb) p do_eval_args(9, 9, 9, 9)
Too few arguments in function call.
(gdb) p do_eval_args(9, 9, 9, 9, 9)
** car: 2 is not a cons
** during evaluation at expr-1:1 of form (raise 5)
** run with --backtrace to enable backtraces
2> _
The function relies on args having enough room for all the values; both callers ensure that. The lookup function can't be wrong, either.The fact that it does error handling is not at all clear from its type signature, in contrast to Rust's function signature. So that's going to contribute "more noise" from your perspective, but on the flip side, it also conveys more information. Based on your demonstration, it looks like your function just aborts the program on an error, but the Rust function is a bit more versatile. It gives the caller a choice of how to deal with an error. Otherwise, I could just write this instead:
fn eval_forms(arg_forms: &[RispExp], env: &mut RispEnv) -> Vec<RispExp> {
arg_forms.iter().map(|x| eval(x, env)).collect()
}The code has two asterisks, both in parameter declarations, indicating pointers.
It has exactly one binary operator in the body, the assignment = denoting the one local side effect (stepping the iteration variable of the simple for (;;) loop).
All else is simple function calls. Except for the function pointer declaration, and perhaps not knowing car and cdr, an Awk or JS programmer might grok this.
The program doesn't abort; the exception was caught in the REPL. We were thrown right out of the GNU Debugger where we caused the problem, and back into the Lisp REPL. We can demonstrate that in other ways, like this:
(gdb) r
Starting program: /home/kaz/txr/txr-dbg
This is the TXR Lisp interactive listener of TXR 215.
Quit with :quit or Ctrl-D on empty line. Ctrl-X ? for cheatsheet.
1> (catch (raise sig-trap) (error (x) (put-line `caught error: @x`)))
Program received signal SIGTRAP, Trace/breakpoint trap.
0x00132416 in __kernel_vsyscall ()
(gdb) p car(9)
caught error: car: 2 is not a cons
t
2> (+ 2 2)
4
3> _
gdb gets confused here, though: 3> (exit 0)
[Inferior 1 (process 8529) exited normally]
The program being debugged exited while in a function called from GDB.
Evaluation of the expression containing the function
(car) will be abandoned.
Quite understandably, it doesn't understand the exception handling and didn't notice that we jumped out; it still thinks we are executing the car function. Oh well! fn eval_forms(arg_forms: &[RispExp], env: &mut RispEnv) -> Result<Vec<RispExp>, RispErr> {
arg_forms
.iter()
.map(|x| eval(x, env))
.collect()
}
You could also make the stylistic change (but not necessarily good change) of wrapping eval to make it less wordy: fn eval_forms(arg_forms: &[RispExp], env: &mut RispEnv) -> Result<Vec<RispExp>, RispErr> {
let eval = |x| eval(x, env);
arg_forms
.iter()
.map(eval)
.collect()
}
At that point, it all fits in one line fn eval_forms(arg_forms: &[RispExp], env: &mut RispEnv) -> Result<Vec<RispExp>, RispErr> {
let eval = |x| eval(x, env);
arg_forms.iter().map(eval).collect()
}
And you could make it so that you don't need to call `iter` inside of the fn on `arg_forms` and potentially accept many things besides slices: fn eval_forms(
arg_forms: impl Iterator<Item=RispExp>,
env: &mut RispEnv,
) -> Result<Vec<RispExp>, RispErr> {
let eval = |x| eval(x, env);
arg_forms.map(eval).collect()
}
:)Then `parse_list_of_floats`, if I am reading it right, this function is fail-safe, not fail-fast, right? It won't panic upon the very first reading error?
Why eval makes environment lookups? Shouldn't environment itself know how to perform a lookup?
The same code has been seen in a bunch of simple/similar lisp interpreters in different languages.
To be honest I'm not sure if that was the first documented tokenizer using this simple approach, but it is definitely a common pattern for such things - and as you say it is naive at best, and buggy at worst. That said it is easy to get it working, and later fix it properly.
> Any sufficiently complicated C or Fortran program contains an ad-hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp.