Lisp (1958) and Forth (1970) share almost nothing syntactically. But both arose from the same conviction: that programming languages are not given facts, but material to be shaped. Both put the programmer in charge of the language itself — and both ideas permeate every serious language that followed.
Most programming languages place the programmer inside the language — constrained by its grammar, its data model, its operator precedence, its permitted abstractions. You can write new functions; you cannot write new syntax. You can call the compiler; you cannot change it. The language is a given.
Lisp and Forth both refuse this premise. In many Lisp systems, large parts of the compiler and runtime live in the language itself and can be inspected or replaced from the running environment. In Forth, the compiler is often little more than a small interpreter plus a dictionary builder; adding a new word to the dictionary is extending the language. Both traditions treat the language itself as the primary material.
The most radical thing about Lisp is not its parentheses — it is what those parentheses mean. A Lisp program is not text that gets parsed into a tree. A Lisp program already is a tree, written out as a list of lists. The notation is the data structure.
This property — that code and data share the same representation — is called homoiconicity. Its consequence: any Lisp program can trivially manipulate any other Lisp program, because both are just lists. The compiler is not special. eval is not magic. They're functions that operate on lists.
// In Lisp, code IS a list. We simulate this in JavaScript. // A Lisp s-expression like (+ (* 2 3) (- 10 4)) is literally: // ['+', ['*', 2, 3], ['-', 10, 4]] — a nested array const program = ['+', ['*', 2, 3], ['-', 10, 4]]; // (+ (* 2 3) (- 10 4)) // eval: a function that evaluates a list-as-code — no special status function lispEval(expr, env = {}) { if (typeof expr === 'number') return expr; // number self-evaluates if (typeof expr === 'string') return env[expr] ?? expr; // symbol lookup if (!Array.isArray(expr)) return expr; const [op, ...args] = expr; if (op === 'quote') return args[0]; // (quote x) → x unevaluated if (op === 'if') return lispEval(args[0], env) ? lispEval(args[1], env) : lispEval(args[2], env); if (op === 'lambda') { const [params, body] = args; return (...vals) => lispEval(body, {...env, ...Object.fromEntries(params.map((p,i)=>[p,vals[i]]))}); } const fn = {'+':(a,b)=>a+b, '-':(a,b)=>a-b, '*':(a,b)=>a*b, 'car':a=>a[0], 'cdr':a=>a.slice(1), 'cons':(h,t)=>[h,...t], 'null?':a=>a.length===0}[op]; if (!fn) throw new Error(`Unknown operator: ${op}`); return fn(...args.map(a => lispEval(a, env))); } // The KEY insight: the program IS data. We can inspect and transform it. console.log("Program as data:", JSON.stringify(program)); console.log("Operator:", program[0]); console.log("First arg:", JSON.stringify(program[1])); // (* 2 3) console.log("Evaluated:", lispEval(program)); // Transform the program — e.g. replace all operators function transformOp(expr, from, to) { if (!Array.isArray(expr)) return expr; return [expr[0] === from ? to : expr[0], ...expr.slice(1).map(e => transformOp(e, from, to))]; } const modified = transformOp(program, '*', '+'); console.log("After replacing * with +:", JSON.stringify(modified)); console.log("Evaluated modified program:", lispEval(modified)); // (quote x) prevents evaluation — returns the list itself as data const quoted = ['quote', ['+', 1, 2]]; console.log("\n(quote (+ 1 2)) →", JSON.stringify(lispEval(quoted))); console.log("Not 3 — the list itself. This is how macros receive their arguments.");
In 1960, Steve Russell noticed something remarkable: McCarthy's paper on Lisp contained an evaluator precise enough to serve as an interpreter. Russell hand-coded that evaluator in machine code, turning the definition into a running system. The important historical point is not that the interpreter literally ran in Lisp first, but that Lisp's definition was already close to executable.
This style of evaluator — eval and apply calling each other recursively — is what Alan Kay called "the Maxwell's equations of software." In a small amount of code you can capture a large fraction of a language's semantics. The demo below is a teaching model in JavaScript, not a literal metacircular Lisp evaluator, but it preserves the core eval/apply idea.
eval answers the question: "what does this expression mean in the current environment?" If the expression is a number, it returns the number. If it is a symbol like x, it looks up x in the environment. If it is a special form like if, quote, or define, it applies the language's special evaluation rules for that form. If it is an ordinary list like (square 7), eval first evaluates the operator square, then evaluates each argument, and hands the results to apply.
apply answers a different question: "now that I already have a function value and a list of argument values, how do I run the call?" For a built-in like +, apply just executes the primitive operation. For a user-defined function, it makes a new environment that binds the parameter names to the argument values, then calls eval on the function body inside that new environment. So for (square 7): eval turns square into a closure and 7 into a value; apply binds x = 7; then eval runs the body (* x x). That back-and-forth is the whole interpreter loop.
eval and apply, you understand much of the language's core semantics. In Lisp culture, the boundary between language definition and implementation became far thinner than in most contemporaries.// A teaching evaluator: Lisp's eval/apply structure shown in JavaScript // Written in plain JS so the page runner can execute it in older browsers too. function makeEnv(pairs, outer) { if (pairs === undefined) pairs = []; if (outer === undefined) outer = null; var bindings = {}; for (var i = 0; i < pairs.length; i++) bindings[pairs[i][0]] = pairs[i][1]; return { lookup: function(name) { if (name in bindings) return bindings[name]; if (outer) return outer.lookup(name); throw new Error('Unbound symbol: ' + name); }, bind: function(name, val) { bindings[name] = val; } }; } function lisp_eval(expr, env) { // Atoms (self-evaluating) if (typeof expr === 'number' || typeof expr === 'boolean') return expr; if (typeof expr === 'string') return env.lookup(expr); if (!Array.isArray(expr)) throw new Error('Unknown expr: ' + expr); var head = expr[0]; var tail = expr.slice(1); // Special forms — handled directly by eval, not apply if (head === 'quote') return tail[0]; if (head === 'if') return lisp_eval(tail[0], env) ? lisp_eval(tail[1], env) : lisp_eval(tail[2], env); if (head === 'define') { env.bind(tail[0], lisp_eval(tail[1], env)); return tail[0]; } if (head === 'begin') { var result = null; for (var j = 0; j < tail.length; j++) result = lisp_eval(tail[j], env); return result; } // Lambda: capture the current environment as a closure if (head === 'lambda') { var params = tail[0]; var body = tail[1]; return { type: 'closure', params: params, body: body, env: env }; } // Function call: eval the operator, then apply it to evaluated args var fn = lisp_eval(head, env); var args = []; for (var k = 0; k < tail.length; k++) args.push(lisp_eval(tail[k], env)); return lisp_apply(fn, args); } function lisp_apply(fn, args) { if (typeof fn === 'function') return fn.apply(null, args); if (fn && fn.type === 'closure') { // Extend the closure's captured environment with the arguments var pairs = []; for (var i = 0; i < fn.params.length; i++) pairs.push([fn.params[i], args[i]]); var newEnv = makeEnv(pairs, fn.env); return lisp_eval(fn.body, newEnv); } throw new Error('Not a function: ' + JSON.stringify(fn)); } // Global environment with primitives var globalEnv = makeEnv([ ['+', function(a, b) { return a + b; }], ['-', function(a, b) { return a - b; }], ['*', function(a, b) { return a * b; }], ['=', function(a, b) { return a === b; }], ['<', function(a, b) { return a < b; }], ['car', function(a) { return a[0]; }], ['cdr', function(a) { return a.slice(1); }], ['cons', function(h, t) { return [h].concat(t); }], ['null?', function(a) { return a.length === 0; }], ['list', function() { return Array.prototype.slice.call(arguments); }] ]); function E(expr) { return lisp_eval(expr, globalEnv); } // Test the evaluator E(['define', 'square', ['lambda', ['x'], ['*', 'x', 'x']]]); console.log("(square 7) =", E(['square', 7])); // Factorial — recursive lambda E(['define', 'fact', ['lambda', ['n'], ['if', ['=', 'n', 0], 1, ['*', 'n', ['fact', ['-', 'n', 1]]]]]]); console.log("(fact 10) =", E(['fact', 10])); // Higher-order: map E(['define', 'mymap', ['lambda', ['f', 'lst'], ['if', ['null?', 'lst'], ['quote', []], ['cons', ['f', ['car', 'lst']], ['mymap', 'f', ['cdr', 'lst']]]]]]); console.log("(map square '(1 2 3 4 5)) =", E(['mymap', 'square', ['quote', [1,2,3,4,5]]])); console.log("\nThe entire evaluator is ~30 lines."); console.log("eval and apply call each other — that mutual recursion IS the language.");
Lisp put higher-order functions at the center very early, and the path from early Lisp's FUNARG problems to Scheme's explicitly lexical scope led directly to the modern notion of a closure. A closure is a function that carries its defining environment with it. When the function is called later, in a different context, it still has access to the variables it saw when it was created.
The FUNARG problem appears as soon as functions become values. Suppose a function returns another function that mentions a local variable: conceptually, make-adder creates (lambda (y) (+ x y)) while x is 10, then returns it. Later, somewhere else, you call that returned function. What should x mean now? The original x = 10 from the place where the function was created, or some other x visible where it is called?
If the language uses the caller's environment, the function can suddenly see the wrong variable, or no variable at all. That was the historical difficulty McCarthy described: early Lisp often got dynamic scope where lexical scope was wanted. The solution is the closure: when the function is created, it carries along the environment it needs. Then the returned adder still remembers x = 10 even after make-adder has finished. In other words, a closure solves the FUNARG problem by packaging code + remembered bindings together.
This seems simple. The implications are vast. Closures are how you implement: objects (data + behaviour bundled), iterators, callbacks, promises, partial application, modules, continuations. Every one of these modern abstractions is a closure in disguise.
// Closures: functions that remember their birth environment // These are modern closure patterns, shown with JavaScript syntax // === COUNTER: mutable state via closure === function makeCounter(initial = 0) { let count = initial; // captured by closure return { increment: () => ++count, decrement: () => --count, value: () => count, reset: () => { count = initial; }, }; } const c = makeCounter(10); c.increment(); c.increment(); c.decrement(); console.log("Counter (init 10, +2, -1):", c.value()); // === OBJECT: closures ARE objects === function makePerson(name, age) { return { // no class needed — closure captures name and age greet: () => `Hi, I'm ${name}`, birthday: () => { age++; return age; }, info: () => `${name}, age ${age}`, }; } const alice = makePerson("Alice", 30); console.log(alice.greet()); alice.birthday(); console.log(alice.info()); // === PARTIAL APPLICATION: fix some arguments === const partial = (f, ...fixed) => (...rest) => f(...fixed, ...rest); const add = (a, b) => a + b; const add5 = partial(add, 5); const triple = partial((a,b)=>a*b, 3); console.log("add5(12) =", add5(12)); console.log("triple(7) =", triple(7)); // === ITERATOR: lazy sequence via closure === function* fibGen() { let [a, b] = [0, 1]; while (true) { yield a; [a, b] = [b, a+b]; } } const fibs = fibGen(); const first10 = Array.from({length:10}, () => fibs.next().value); console.log("First 10 Fibonacci:", first10.join(" ")); // === Y COMBINATOR: recursion without names (pure lambda calculus) === const Y = f => (x => f(v => x(x)(v)))(x => f(v => x(x)(v))); const factY = Y(self => n => n <= 1 ? 1 : n * self(n - 1)); console.log("Y combinator factorial(8) =", factY(8)); console.log("(Lambda calculus has no names — Y lets recursion happen without them)");
Lisp macros are the most powerful metaprogramming mechanism in mainstream use. A macro is a function that receives its arguments unevaluated (as lists) and returns a new list (code) that is then evaluated. The macro runs at compile time, transforming code before it runs.
This means you can add new control structures, new syntax, new binding forms to the language — and they are indistinguishable from built-ins. when, unless, while, defclass, with-open-file — none of these are special forms in Common Lisp. They're macros. The language is grown by its users.
// Lisp macros: functions on code (lists) that produce new code (lists) // They run at compile time, transforming s-expressions before evaluation const macroTable = {}; function defmacro(name, transformer) { macroTable[name] = transformer; // register the macro } function macroExpand(expr) { if (!Array.isArray(expr)) return expr; if (macroTable[expr[0]]) { // is the head a macro? const expanded = macroTable[expr[0]](...expr.slice(1)); // call transformer on raw args return macroExpand(expanded); // recursively expand result } return expr.map(macroExpand); // expand inside all subforms } // === WHEN: (when condition body...) → (if condition (begin body...) nil) === defmacro('when', (condition, ...body) => ['if', condition, ['begin', ...body], null] ); // === AND: short-circuit conjunction === defmacro('and', (...forms) => { if (forms.length === 0) return true; if (forms.length === 1) return forms[0]; return ['if', forms[0], ['and', ...forms.slice(1)], false]; }); // === LET*: sequential binding === // (let* ((x 1) (y (+ x 1))) body) → nested lambdas defmacro('let*', (bindings, body) => { if (bindings.length === 0) return body; const [[name, val], ...rest] = bindings; return [['lambda', [name], ['let*', rest, body]], val]; }); // === -> THREADING MACRO: pipe values through functions === // (-> x f g h) → (h (g (f x))) defmacro('->', (val, ...fns) => fns.reduce((acc, fn) => Array.isArray(fn) ? [fn[0], acc, ...fn.slice(1)] : [fn, acc], val) ); // Demonstrate macro expansion const whenExpr = ['when', ['>', 'x', 0], ['print', 'positive']]; console.log("(when (> x 0) (print 'positive'))"); console.log("expands to:", JSON.stringify(macroExpand(whenExpr))); const threadExpr = ['->', 5, ['*', 2], ['+', 1], ['*', 3]]; console.log("\n(-> 5 (* 2) (+ 1) (* 3)) ← clean pipeline"); console.log("expands to:", JSON.stringify(macroExpand(threadExpr))); console.log("which reads: (* (+ (* 5 2) 1) 3) = ", (((5*2)+1)*3)); // GENSYM: macros need fresh names to avoid variable capture let gensymCounter = 0; const gensym = prefix => `${prefix}__${gensymCounter++}`; defmacro('swap!', (a, b) => { const tmp = gensym('tmp'); // fresh name — can't collide with user vars return ['let*', [[tmp, a]], ['begin', ['set!', a, b], ['set!', b, tmp]]]; }); console.log("\n(swap! x y) expands to:", JSON.stringify(macroExpand(['swap!', 'x', 'y']))); console.log("Fresh gensym avoids capturing user's 'tmp' variable if it exists");
Forth has no variables in the conventional sense. No registers. No named temporaries. All computation happens on a data stack: you push values, apply operations, and the result replaces the operands. There's also a return stack for call management — and that's it. The entire computational model fits on a whiteboard.
Arithmetic in Forth is postfix (Reverse Polish Notation): 2 3 + pushes 2, pushes 3, then + pops both and pushes 5. This is not just notation — it's the actual execution model. No parsing of operator precedence. No expression tree. The stack IS the computation.
// A Forth stack machine — the entire computational model // Written in plain JS so the page runner can execute it reliably. function ForthMachine() { this.stack = []; this.rstack = []; this.memory = {}; this.dict = this.buildDict(); } ForthMachine.prototype.push = function(v) { this.stack.push(v); }; ForthMachine.prototype.pop = function() { if (!this.stack.length) throw new Error("Stack underflow"); return this.stack.pop(); }; ForthMachine.prototype.peek = function() { return this.stack[this.stack.length - 1]; }; ForthMachine.prototype.buildDict = function() { var m = this; return { // Arithmetic — pop two, push result '+': function() { m.push(m.pop() + m.pop()); }, '-': function() { var b = m.pop(), a = m.pop(); m.push(a - b); }, '*': function() { m.push(m.pop() * m.pop()); }, '/': function() { var b = m.pop(), a = m.pop(); m.push(Math.floor(a / b)); }, 'MOD': function() { var b = m.pop(), a = m.pop(); m.push(a % b); }, 'NEGATE': function() { m.push(-m.pop()); }, 'ABS': function() { m.push(Math.abs(m.pop())); }, 'MAX': function() { m.push(Math.max(m.pop(), m.pop())); }, // Stack manipulation — the "no variables" toolkit 'DUP': function() { m.push(m.peek()); }, 'DROP': function() { m.pop(); }, 'SWAP': function() { var b = m.pop(), a = m.pop(); m.push(b); m.push(a); }, 'OVER': function() { m.push(m.stack[m.stack.length - 2]); }, 'ROT': function() { var c = m.pop(), b = m.pop(), a = m.pop(); m.push(b); m.push(c); m.push(a); }, '2DUP': function() { var b = m.stack[m.stack.length - 1], a = m.stack[m.stack.length - 2]; m.push(a); m.push(b); }, // Comparison '=': function() { m.push(m.pop() === m.pop() ? -1 : 0); }, '<': function() { var b = m.pop(), a = m.pop(); m.push(a < b ? -1 : 0); }, '>': function() { var b = m.pop(), a = m.pop(); m.push(a > b ? -1 : 0); }, '0=': function() { m.push(m.pop() === 0 ? -1 : 0); }, // Output '.': function() { console.log(m.pop()); }, '.S': function() { console.log("Stack:", m.stack.slice()); } }; }; ForthMachine.prototype.run = function(source) { var tokens = source.trim().split(/\s+/); for (var i = 0; i < tokens.length; i++) { var token = tokens[i]; if (token === "") continue; if (!isNaN(token)) { this.push(Number(token)); continue; } var word = this.dict[token.toUpperCase()]; if (!word) throw new Error("Unknown word: " + token); word(); } return this.stack; }; var forth = new ForthMachine(); // No variables needed! Stack manipulation suffices. console.log("=== Forth stack computation ==="); console.log("2 3 + 4 * → ", forth.run("2 3 + 4 *")); // (2+3)*4 = 20 forth.stack=[]; console.log("5 DUP * → ", forth.run("5 DUP *")); // 5*5 = 25 forth.stack=[]; console.log("10 3 MOD → ", forth.run("10 3 MOD")); // 10 mod 3 = 1 forth.stack=[]; // Hypotenuse without naming variables: √(a²+b²) console.log("Hypotenuse: ", forth.run("3 DUP * 4 DUP * + ")); // 3²+4² = 25 console.log("(√25 not implemented, but 3 DUP * 4 DUP * + gives 25)"); forth.stack=[]; // ROT reorders: a b c → b c a forth.run("1 2 3"); console.log("\nBefore ROT: [1 2 3]"); forth.run("ROT"); console.log("After ROT: ", forth.stack);
In Forth, the compiler and interpreter share a single structure: the dictionary. Every word — whether a built-in primitive or something you just defined — is an entry in the dictionary. Defining a new word is not "writing a function"; it is adding a word to the language.
The colon definition : SQUARE DUP * ; adds SQUARE to the dictionary. From that point, SQUARE is indistinguishable from any built-in. You can extend Forth with domain-specific words until your code reads like the specification of the problem itself.
// Forth's dictionary: define new words from old ones // The language grows from the bottom up — primitives to domain words class ForthDict { constructor() { this.words = {}; this.stack = []; this.compiling = false; this.newWordName = null; this.newWordTokens = []; this.addPrimitives(); } addPrimitives() { const p = (name, fn) => { this.words[name] = { type: 'primitive', fn }; }; p('+', ()=>this.stack.push(this.stack.pop()+this.stack.pop())); p('*', ()=>this.stack.push(this.stack.pop()*this.stack.pop())); p('-', ()=>{const b=this.stack.pop(),a=this.stack.pop();this.stack.push(a-b);}); p('DUP', ()=>this.stack.push(this.stack.at(-1))); p('DROP', ()=>this.stack.pop()); p('SWAP', ()=>{const b=this.stack.pop(),a=this.stack.pop();this.stack.push(b,a);}); p('OVER', ()=>this.stack.push(this.stack.at(-2))); p('ROT', ()=>{const[c,b,a]=[this.stack.pop(),this.stack.pop(),this.stack.pop()];this.stack.push(b,c,a);}); } execute(token) { if (!isNaN(token)) { this.stack.push(Number(token)); return; } const word = this.words[token.toUpperCase()]; if (!word) throw new Error(`Undefined: ${token}`); if (word.type === 'primitive') word.fn(); else word.tokens.forEach(t => this.execute(t)); } run(source) { const tokens = source.trim().split(/\s+/); for (const tok of tokens) { if (tok === ':') { this.compiling = true; continue; } // start definition if (tok === ';') { // end definition this.words[this.newWordName] = { type: 'defined', tokens: this.newWordTokens }; console.log(`Defined: ${this.newWordName} = [${this.newWordTokens.join(' ')}]`); this.compiling = false; this.newWordName = null; this.newWordTokens = []; continue; } if (this.compiling) { if (!this.newWordName) { this.newWordName = tok.toUpperCase(); continue; } this.newWordTokens.push(tok.toUpperCase()); continue; } this.execute(tok); } return [...this.stack]; } } const f = new ForthDict(); // Build up a vocabulary layer by layer console.log("=== Building a language from primitives ==="); // Layer 1: Basic combinators f.run(": SQUARE DUP * ;"); // SQUARE: n → n² f.run(": CUBE DUP SQUARE * ;"); // CUBE: uses SQUARE f.run(": NIP SWAP DROP ;"); // NIP: a b → b f.run(": TUCK SWAP OVER ;"); // TUCK: a b → b a b // Layer 2: Domain words built from layer 1 f.run(": HYPOTENUSE-SQUARED SQUARE SWAP SQUARE + ;"); f.stack=[]; console.log("\n5 SQUARE =", f.run("5 SQUARE")); f.stack=[]; console.log("3 CUBE =", f.run("3 CUBE")); f.stack=[]; console.log("3 4 HYPOTENUSE-SQUARED =", f.run("3 4 HYPOTENUSE-SQUARED")); console.log("\nDictionary:", Object.keys(f.words).join(" ")); console.log("All words are equal — primitives and defined words are indistinguishable.");
Forth is a concatenative language. This means: putting two programs next to each other produces their composition. A B means "run A, then run B on whatever A left on the stack." Function composition is just concatenation of source text.
This has deep algebraic consequences. Programs are not expressions that evaluate to values — they are functions from stack state to stack state, and juxtaposition is their composition operator. The entire language has no syntax beyond this: a program is a sequence of words.
// Concatenative programming: programs ARE functions Stack→Stack // Juxtaposition = function composition. No other syntax needed. // Model a stack-to-stack function const word = (name, fn) => ({ name, fn }); // Primitives — each transforms a stack const DUP = word('DUP', s => [...s, s.at(-1)]); const DROP = word('DROP', s => s.slice(0,-1)); const SWAP = word('SWAP', s => [...s.slice(0,-2), s.at(-1), s.at(-2)]); const OVER = word('OVER', s => [...s, s.at(-2)]); const PLUS = word('+', s => [...s.slice(0,-2), s.at(-2)+s.at(-1)]); const TIMES = word('*', s => [...s.slice(0,-2), s.at(-2)*s.at(-1)]); const MINUS = word('-', s => [...s.slice(0,-2), s.at(-2)-s.at(-1)]); const LIT = n => word(String(n), s => [...s, n]); // CONCATENATION = composition. Put words next to each other → compose them. const concat = (...words) => ({ name: words.map(w=>w.name).join(' '), fn: stack => words.reduce((s, w) => w.fn(s), stack) }); // DEFINE higher-level words by concatenating lower-level ones const SQUARE = concat(DUP, TIMES); // n → n² (= DUP *) const CUBE = concat(DUP, SQUARE, TIMES); // n → n³ (= DUP SQUARE *) const NIP = concat(SWAP, DROP); // a b → b const TUCK = concat(SWAP, OVER); // a b → b a b // Run a "program" = a composed word on an initial stack const run = (prog, init=[]) => prog.fn(init); console.log("SQUARE 5 → ", run(concat(LIT(5), SQUARE))); console.log("CUBE 3 → ", run(concat(LIT(3), CUBE))); console.log("TUCK 2 7 → ", run(concat(LIT(2), LIT(7), TUCK))); // KEY INSIGHT: program text = just a sequence of names. No parse tree. // "3 DUP * 4 DUP * +" is literally [LIT(3), DUP, TIMES, LIT(4), DUP, TIMES, PLUS] const hyp_sq = concat(LIT(3), DUP, TIMES, LIT(4), DUP, TIMES, PLUS); console.log("3 DUP * 4 DUP * + → ", run(hyp_sq), "(= 3² + 4² = 25)"); // Type-level: every Forth word has stack effect ( before -- after ) // DUP : ( n -- n n ) ← consumes 1, produces 2 // DROP : ( n -- ) ← consumes 1, produces 0 // SWAP : ( a b -- b a ) ← consumes 2, produces 2 // OVER : ( a b -- a b a ) ← consumes 2, produces 3 // + : ( a b -- a+b ) ← consumes 2, produces 1 // SQUARE: ( n -- n² ) ← derived: (DUP *) console.log("\nStack effect notation encodes the type of each word."); console.log("Factor (modern Forth) uses this to infer types statically.");
Forth's most radical feature is that much of its compiler can be expressed in the language itself. A colon definition like : SQUARE DUP * ; does not invoke a giant separate compiler pipeline; it switches the system into "compile mode," reads words one by one, and appends their behavior to the new definition. In many Forth systems the words that manage this process live in the same dictionary as ordinary words.
Immediate words are the key mechanism. Most words encountered inside a definition are compiled: the compiler records "call this word later at runtime." But an immediate word executes right now while compilation is happening. That lets it modify the code being generated. The classic example is ;: instead of being compiled into the new word, it runs immediately and says "the definition ends here."
Control structures work the same way. In a line like : ABS-SIGN DUP 0= IF DROP 0 ELSE DUP * THEN ;, the arithmetic words such as DUP, DROP, and * are compiled as normal runtime operations. But IF, ELSE, and THEN run during compilation. IF emits a conditional branch with an unknown destination. ELSE fills in the earlier branch target and emits a new jump over the else-part. THEN finally fills in the last missing destination. That "fill in the address later" step is called backpatching.
// Forth meta-compilation: control structures built from IMMEDIATE words // In standard Forth: IF, ELSE, THEN, BEGIN, UNTIL are all defined in Forth itself // Here we simulate the mechanism that makes this possible class MetaForth { constructor() { this.dict = {}; this.stack = []; this.compiling = false; this.codeBuffer = []; // instructions being compiled this.patches = []; // addresses needing backpatching (for forward jumps) this.addPrimitives(); this.addControlWords(); } addPrimitives() { const add = (name, fn, immediate=false) => { this.dict[name] = {fn, immediate}; }; add('+', ()=>{ const[b,a]=[this.stack.pop(),this.stack.pop()]; this.stack.push(a+b); }); add('-', ()=>{ const[b,a]=[this.stack.pop(),this.stack.pop()]; this.stack.push(a-b); }); add('*', ()=>{ const[b,a]=[this.stack.pop(),this.stack.pop()]; this.stack.push(a*b); }); add('DUP', ()=>this.stack.push(this.stack.at(-1))); add('DROP',()=>this.stack.pop()); add('0=', ()=>this.stack.push(this.stack.pop()===0?-1:0)); add('.', ()=>console.log(this.stack.pop())); } addControlWords() { // IMMEDIATE words run at COMPILE TIME, not runtime // They can insert instructions and patch jump addresses const add = (name, fn) => { this.dict[name] = {fn, immediate: true}; }; // IF: ( flag -- ) branch if false // Compiles a conditional jump; records address for ELSE/THEN to patch add('IF', () => { this.codeBuffer.push({ op: 'BRANCH_FALSE', target: null }); this.patches.push(this.codeBuffer.length - 1); // remember to backpatch console.log(` [compile-time] IF: emitted BRANCH_FALSE at [${this.codeBuffer.length-1}]`); }); // ELSE: patch the IF's branch, emit unconditional jump for THEN add('ELSE', () => { this.codeBuffer.push({ op: 'JUMP', target: null }); const elseAddr = this.codeBuffer.length - 1; const ifAddr = this.patches.pop(); this.codeBuffer[ifAddr].target = this.codeBuffer.length; // patch IF to jump here this.patches.push(elseAddr); console.log(` [compile-time] ELSE: patched IF[${ifAddr}]→${this.codeBuffer.length}, emitted JUMP[${elseAddr}]`); }); // THEN: patch IF or ELSE's jump to land here add('THEN', () => { const patchAddr = this.patches.pop(); this.codeBuffer[patchAddr].target = this.codeBuffer.length; console.log(` [compile-time] THEN: patched [${patchAddr}]→${this.codeBuffer.length}`); }); // BEGIN/UNTIL: loop until condition is true add('BEGIN', () => { this.patches.push(this.codeBuffer.length); // remember loop start }); add('UNTIL', () => { const loopStart = this.patches.pop(); this.codeBuffer.push({ op: 'BRANCH_FALSE', target: loopStart }); console.log(` [compile-time] UNTIL: emit BRANCH_FALSE→${loopStart}`); }); } compileWord(name, tokens) { console.log(`\nCompiling: ${name} [ ${tokens.join(' ')} ]`); this.codeBuffer = []; for (const tok of tokens) { if (!isNaN(tok)) { this.codeBuffer.push({op:'LIT',val:Number(tok)}); continue; } const w = this.dict[tok.toUpperCase()]; if (!w) throw new Error(`Unknown: ${tok}`); if (w.immediate) w.fn(); // IMMEDIATE: run NOW at compile time else this.codeBuffer.push({op:'CALL', word: tok.toUpperCase()}); } this.dict[name] = { fn: () => this.executeCode(this.dict[name].code), code: [...this.codeBuffer] }; console.log(" Compiled bytecode:", this.codeBuffer.map(i=>i.op+(i.val??i.word??('→'+(i.target??'?')))).join(' ')); } executeCode(code) { let ip = 0; while (ip < code.length) { const ins = code[ip]; if (ins.op === 'LIT') { this.stack.push(ins.val); ip++; continue; } if (ins.op === 'CALL') { this.dict[ins.word].fn(); ip++; continue; } if (ins.op === 'BRANCH_FALSE') { const flag = this.stack.pop(); ip = flag === 0 ? ins.target : ip + 1; continue; } if (ins.op === 'JUMP') { ip = ins.target; continue; } throw new Error(`Unknown opcode: ${ins.op}`); } return this.stack; } } const mf = new MetaForth(); // Compile an IF/ELSE/THEN word — the control structure is built by IMMEDIATE words mf.compileWord('ABS-SIGN', ['DUP','0=','IF','DROP','0','ELSE','DUP','*','THEN']); mf.stack = [0]; mf.dict['ABS-SIGN'].fn(); console.log("ABS-SIGN on 0 →", mf.stack); mf.stack = [3]; mf.dict['ABS-SIGN'].fn(); console.log("ABS-SIGN on 3 →", mf.stack); console.log("\nIF ELSE THEN are not special syntax — they are IMMEDIATE words"); console.log("that run at compile time and emit/patch jump instructions."); console.log("Any Forth programmer can write their own control structures the same way.");
Many central ideas in modern programming languages passed through Lisp or Forth before spreading more widely. Their influence shows up clearly in later languages and traditions including Smalltalk, Self, JavaScript, Python, Ruby, Lua, Clojure, Factor, and parts of Rust.
| Idea | Originates in | Now found in |
|---|---|---|
| Garbage collection | Lisp (1958) | Java, C#, Python, Go, JS, Ruby — virtually everything |
| First-class functions / lambdas | Lisp (1958) | Python, JS, Haskell, C# (LINQ), Java 8, Rust, Swift |
| Lexical closures | Scheme / Lisp tradition (1970s) | JavaScript, Python, Ruby, Go, Rust, Swift, Kotlin |
| REPLs | Lisp (1960) | Python, Ruby (irb), Node.js, Julia, R, Haskell (ghci) |
| Macros / compile-time metaprogramming | Lisp (1963) | Rust (macro_rules!), Elixir, Nim, Racket, Julia |
| Homoiconicity | Lisp (1958) | Clojure, Racket, Julia, Prolog (terms), Rebol |
| Stack-oriented language design | Forth (1970) | JVM bytecode, .NET CLR, WebAssembly, PostScript, Factor |
| Concatenative / point-free style | Forth (1970) | Factor, Joy, Cat, Haskell (point-free), Unix pipes |
| Extensible compiler (immediate words) | Forth (1970) | Rust proc-macros, Racket #lang, Elixir macros |
| Embedded DSL creation | Both | Ruby (Rails DSL), Groovy, Clojure, Kotlin |
| Language-as-material philosophy | Both | Smalltalk, Self, Io, Rebol, Factor, Racket |