4 ms·
Thanks for that. I kinda know how it "works" at the user-level. I meant to say, I don't know how it is implemented. My mental model of Forth is that there is a
by bxparks 1y ago
Thanks for that. I kinda know how it "works" at the user-level. I meant to say, I don't know how it is implemented.
My mental model of Forth is that there is a simple parser that consumes space-delimited keywords. The interpreter looks up that keyword in a dictionary, which gives the address of the machine code that handles that word. The interpreter either makes a subroutine call to that address (subroutine threaded), or jumps to that address (called "direct threaded" if I recall, where the handler jumps back to the interpreter instead of executing a RET).
But that's where my mental model of Forth breaks down, because IF-THEN-ELSE cannot be implemented in that model. So there must be something else fundamental in the Forth interpreter that I don't understand.
- addaon 1y ago> So there must be something else fundamental in the Forth interpreter that I don't understand. The missing bit is IMMEDIATE mode. Words can be tagged as IMMEDIATE, which means that they get executed at compile time (or parse time, for an interpreter), rather than a call to them getting compiled (executed at run time, for an interpreter). IF/ELSE/THEN are then "just" IMMEDIATE mode words -- but you can add your own. The "special sauce" is that IF compiles (easier to talk about for a compiler; generalize as needed) a conditional branch to an unknown address, and puts the address of that branch instruction (or an equivalent) on the /compile time/ data stack; THEN then looks at the address on the /compile time/ data stack and patches that instruction to branch to the correct address. Plenty of subtlety possible, but the basic primitive of IMMEDIATE mode is the key.
- bxparks 1y agoAh I see, this is peeling back a few layers of obscurity about the Forth interpreter for me. Let's stick with a Forth interpreter because that seems easier to think about for me. Are you saying that the Forth interpreter is a 2-pass interpreter? Or does the interpreter go into a special IMMEDIATE mode upon hitting the IF keyword, then it just consumes subsequent tokens without doing any dispatching, until it hits the THEN token? It sounds like nested IF-THEN-ELSE becomes tricky to handle. How does the FORTH interpreter handle loops? Does the interpreter hit the WHILE token, goes into IMMEDIATE mode, remembers the location of the WHILE, then dispatches all the subsequent code, until it hits the REPEAT token, then branches back to the WHILE?
- vdupras 1y agoNo, that's not it. It's much simpler than that, yet has much deeper implications than you think. You don't see it in other languages. The closest thing would maybe be compile-time macros in Zig? But in Forth, the power it unlocks comes in its purest form, without any fluff around it.
- addaon 1y agoI've actually never worked with a "pure" interpreter in Forth, only compilers of various levels of complexity. Threaded code compilers are (in my experience) by far the most common way to deal with forth -- and they are very much 2-pass. Even when used as an "interpreter," they generate (trivial, usually) machine code, then jump to it. Consider a definition (in some ill-defined Forth variant) like : abs-sqr ( n -- |n^2| ) * 0 < if neg then ; We can categorize things: IMMEDIATE words used here are : ( if then ; Normal words are * < neg Literals are 0 Tokens that are not seen by the compiler directly (!) are abs-sqr, the contents of the comment, and ) So the compiler goes through one token (that it sees) at a time. First up is `:`. `:` is an IMMEDIATE word, so the compiler just calls it now. `:` then consumes a symbol (`abs-sqr`) from the token stream so the compiler won't see it (think of calling next() on an iterator in python or equivalent), then creates a symbol table entry from that symbol to the /current compiled code output stream pointer/ -- that is, just after the last piece of code that was compiled. Next up is `(`, since we already consumed `abs-sqr`. This is an IMMEDIATE word again -- and it just consumes tokens until one of them is exactly `)`, discarding them -- that is, it defines a comment. Finally we get to the "easy" case, `*`. The compiler finally compiles! It looks up this symbol in the symbol table, sees that it is /not/ IMMEDIATE, and compiles a call to this address. Now the compiler sees `0`. This is a literal token, so we don't even bother with the symbol table; we special-case code to push this value on the stack. '<' is a non-IMMEDIATE symbol, we already know this case. We've already discussed `if`, `neg`, and `then`. And `;` is an IMMEDIATE word that just puts a return instruction into the code stream. Clear as mud? There's one more step from here that's important to make, which is that the choice of what's IMMEDIATE or not is not strictly defined. Some words must be IMMEDIATE for correctness, if they interact with the compiler in interesting ways, like consuming tokens or back-patching instructions. But suppose we want to be clever... `<` works fine as a non-IMMEDIATE word. If we want to inline it, we /could/ have the compiler generalize by looking at the instructions pointed to by it, seeing how long they are (or tracking that in the symbol table), and deciding whether to inline... or we can just re-implement `<` as an immediate word that adds the appropriate instructions directly into the code stream. Combined with assembly words, this can be pretty trivially expressed, and it really changes the paradigm a bit.
- vdupras 1y agoAs @addaon writes, your missing ingredient is immediateness. This is one of the most powerful, yet mind-boggling aspects of Forth. I encourage you to check it out, it will make you grow as a developer.
- bxparks 1y agoI will definitely look into that. If understanding this special IMMEDIATE mode is required to understand the Forth interpreter for something as fundamental as control-flow, it seems fair to say that Forth is not a simple language. It's not just an advanced programmable RPN calculator An RPN calculator has a program counter, which makes control-flow easy to understand. In comparison, C is a high level language, but the mapping from C code to assembly language is relatively simple. (Yes, compiler optimizations against the C "abstract machine" can make the resulting code completely obscure. But if we turn off optimization, the resulting assembly code matches the C code fairly directly.)
- vdupras 1y agoThe mapping to assembly of: 42 = if ."hey!" then is much more straightforward than if (n == 42) printf("hey!"); I understand that to the newcomer, it might not appear that way, but implementing a Forth is really eye-opening in that regard. If I might allow myself a bit of promotion, I wrote https://tumbleforth.hardcoded.net/ https://tumbleforth.hardcoded.net/ as such an eye-opening process. It's less "gentle" than Easy Forth here, but it digs deeper.
- bxparks 1y agoFrom the comments in this thread, it seems that to understand how Forth implements a simple IF-THEN-ELSE control-flow, I have to understand the difference between non-immediate and immediate words. I also have to understand the difference between outer and inner interpreter. And I have to understand how Forth generates snippets of machine code (where does that get stored? I thought Forth only has 2 stacks, does it also have a general heap?). Then understand how the THEN token goes back and patches the placeholder address generated by the IF token. And understand the difference between the parsing phase and the interpreted phase of the Forth interpreter/compiler. But you are saying that the Forth version is simpler than C version which will kinda look like this after it's compiled (Z80 assembly code, it's in my head right now): ld a, (variableN) cp 42 ld hl, StringHey call z, Printf ... StringHey: .db "hey!", 0 I find that hard to believe, but I accept that you believe that.
- kragen 1y agoYou're describing the outer interpreter in interpretation state; Forth control flow words don't work properly in interpretation state, only in compile state. They're immediate words, so they execute at compile time instead of run time, so they can do arbitrary things to the code being compiled. Here's Mike Perry and Henry Laxen's implementation of the main control-flow words from F83, which is an indirect-threaded Forth: \ Run Time Code for Control Structures 04OCT83HHL \ \ Run Time Code for Control Structures 05MAR83HHL CODE BRANCH (S -- ) \ BRANCH Performs an unconditional branch. Notice that we LABEL BRAN1 0 [IP] IP MOV NEXT END-CODE \ are using absolute addresses insead of relative ones. (fast) CODE ?BRANCH (S f -- ) \ ?BRANCH Performs a conditional branch. If the top of the AX POP AX AX OR BRAN1 JE IP INC IP INC NEXT END-CODE \ parameter stack in True, take the branch. If not, skip \ over the branch address which is inline. \ Extensible Layer Structures 03Apr84map \ \ Extensible Layer Structures 03Apr84map : ?CONDITION (S f -- ) \ ?CONDITION NOT ABORT" Conditionals Wrong" ; \ Simple compile time error checking. Usually adequate : >MARK (S -- addr ) HERE 0 , ; \ >MARK Set up for a Forward Branch : >RESOLVE (S addr -- ) HERE SWAP ! ; \ >RESOLVE Resolve a Forward Branch : <MARK (S -- addr ) HERE ; \ <MARK Set up for a Backwards Branch : <RESOLVE (S addr -- ) , ; \ <RESOLVE Resolve a Backwards Branch : ?>MARK (S -- f addr ) TRUE >MARK ; \ ?>MARK Set up a forward Branch with Error Checking : ?>RESOLVE (S f addr -- ) SWAP ?CONDITION >RESOLVE ; \ ?>RESOLVE Resolve a forward Branch with Error Checking : ?<MARK (S -- f addr ) TRUE <MARK ; \ ?<MARK Set up for a Backwards Branch with Error Checking : ?<RESOLVE (S f addr -- ) SWAP ?CONDITION <RESOLVE ; \ ?<RESOLVE Resolve a backwards Branch with Error Checking : LEAVE COMPILE (LEAVE) ; IMMEDIATE \ LEAVE and ?LEAVE could be non-immediate in this system, : ?LEAVE COMPILE (?LEAVE) ; IMMEDIATE \ but the 83 standard specifies an immediate LEAVE, so they \ both are for uniformity. \ Extensible Layer Structures 01Oct83map \ \ Extensible Layer Structures 27JUL83HHL : BEGIN ?<MARK ; IMMEDIATE \ These are the compiling words needed to properly compile : THEN ?>RESOLVE ; IMMEDIATE \ the Forth Conditional Structures. Each of them is immediate : DO COMPILE (DO) ?>MARK ; IMMEDIATE \ and they must compile their runtime routines along with : ?DO COMPILE (?DO) ?>MARK ; IMMEDIATE \ whatever addresses they need. A modest amount of error : LOOP \ checking is done. If you want to rip out the error checking COMPILE (LOOP) 2DUP 2+ ?<RESOLVE ?>RESOLVE ; IMMEDIATE \ change the ?> and ?< words to > and < words, and : +LOOP \ all of the 2DUPs to DUPs and the 2SWAPs to SWAPs. The rest COMPILE (+LOOP) 2DUP 2+ ?<RESOLVE ?>RESOLVE ; IMMEDIATE \ should stay the same. : UNTIL COMPILE ?BRANCH ?<RESOLVE ; IMMEDIATE : AGAIN COMPILE BRANCH ?<RESOLVE ; IMMEDIATE : REPEAT 2SWAP [COMPILE] AGAIN [COMPILE] THEN ; IMMEDIATE : IF COMPILE ?BRANCH ?>MARK ; IMMEDIATE : ELSE COMPILE BRANCH ?>MARK 2SWAP ?>RESOLVE ; IMMEDIATE : WHILE [COMPILE] IF ; IMMEDIATE When the interpreter is toodling along in compile state, compiling a colon definition by stowing pointers one after another (at the pointer here) into the definition of some word you're compiling, and it encounters an if, it sees that if is immediate, and so instead of stowing a pointer to if it just runs it immediately. The definition of if is compile ?branch ?>mark. compile is also an immediate word [correction, no, it's not, see below comment, though the following is still correct]; compile ?branch stows a pointer to ?branch into the colon definition being compiled, and then ?>mark writes a 0 into the entry following the ?branch and pushes true and the address of the 0 on the operand stack, at compile time, with the sequence true here 0 ,. The interpreter toodles along compiling the body of the if and eventually gets to, for example, then, which is also immediate, and is defined as ?>resolve, which overwrites the 0 into the address of the indirect-threaded code that will be compiled following the then. It does this with swap ?condition here swap !. The swap ?condition part aborts with an error if there isn't an unresolved if or similar on the stack to resolve, consuming the true, leaving only the address of the 0 that ?>mark had pushed. So then here swap ! overwrites that 0 with the current value of here. ?branch is a word written in assembly which does a conditional jump in the inner interpreter (the one that interprets the indirect-threaded code); when it's executed, it pops a value off the stack and checks to see if it's zero, and if so, it changes the interpreter's execution pointer ip (which is defined elsewhere as the register si) to the number stored in the threaded code following the pointer to ?branch. If, on the other hand, the value it popped was nonzero, it increments ip twice to skip over that number. (Note that Laxen's comment on ?branch is incorrect in that it reverses the sense of the test.) All the forward jumps work in pretty much the same way: when you begin a control structure you call ?>mark to write a zero placeholder and push its address, and later on you "resolve" that placeholder by popping its address off the stack and overwriting it with the correct address. leave (break) and ?leave (if (...) break) work slightly differently, but mostly the same. Backward jumps work the other way around: when you begin a control structure, as in begin, you call ?<mark to save the current address on the stack so that you can jump to it later, which ends up just being true here. Then, to actually compile the jump, for example in until or again, you call ?<resolve, which ends up just being swap ?condition ,—the , pops the jump target address off the stack and compiles it into the indirect threaded code, serving as an argument the ?branch or branch instruction compiled immediately before it. begin ... while ... repeat is handled, as you can see, by treating the while ... repeat part as an if ... then with an unconditional jump back to the begin jammed in right before the then. Hopefully this is helpful! BTW, for the above, I reformatted the block files from the F83 distribution with http://canonical.org/~kragen/sw/dev3/blk2unix.py http://canonical.org/~kragen/sw/dev3/blk2unix.py, which you may find useful if you want to do the same thing.