This is a good point. One of the things I have to balance with the book is teaching the optimal way to do something without dragging through reader through a large amount of verbose, grungy code. So sometimes (but not too often) I take a simpler approach even if it's not the best one.
With this VM, we have plenty of opcode space left, so it's natural to just add another instruction for inheritance, even if it does mean that the bytecode dispatch loop doesn't fit in the instruction cache quite as well.
I'll think about this some more. I'm very hesitant to make sweeping changes to the chapter (I want nothing more in life than for the book to be done), but this would be a good place to teach readers this fundamental technique of compiling language constructs to runtime function calls.
Probably sufficient to add it as a chapter footnote/challenge like you did with flexible array members for the string implementation. Maybe with a pointer to an example if you have time to find one.
> technique of compiling language constructs to runtime function calls.
That can be as basic as generating a function call AST node directly from a construct, or doing a node-to-node transformation.
The compiler doesn't see anything but a function call node (though any source code tracking info and whatnot still references the original construct).
If you're doing things with the AST other than just generating code, you may want to have a node for that original construct. (For instance, feeding cross-referencing info to a language server or whatever.)
The implementation is actually a single-pass compiler so it goes from token stream straight to bytecode generation with no intermediate AST or other IR in between. Even so, it would be straightforward to emit the bytecode instruction sequence for a call to a special runtime function.
Well, the thing about this approach, I think, is that your code itself essentially becomes something like an image of your syntax tree (a la recursive descent parsing). That's fine if you want a sort of "toy" interpreter or interpreter that doesn't care about speed much.
But you're also using bytecode, which involves more caring about speed, so you've got a bit of a mismatch - as your code tries to match the multiple layers you're skipping, your code will get more complicated or your VM gets more arbitrary, so of the situation you have here.
> or interpreter that doesn't care about speed much.
This is the same approach taken by Lua and many of the original Pascal compilers, which had to run on very slow hardware, so there's pretty good precedent that you can get adequate performance.
> But you're also using bytecode, which involves more caring about speed, so you've got a bit of a mismatch
I wouldn't describe it as a mismatch as much as it is a trade-off. Because the compiler only has a peephole view into the source when it needs to generate code, many optimizations are off the table. However, because we have full control over the instruction set, we can sometimes tweak the bytecode format in order to more naturally align with the compiler.
In return for not needing an intermediate representation or AST, we get a much simpler compiler, especially in C where you have to worry about memory management.
It is reasonable to say this is a tradeoff. However, one of the costs is having the VM structure depend on the language structure, which kind of a software engineering no-no. But yes, tradeoffs always happen.
Optimization along any axis (code size, simplicity, compile-time performance, runtime performance, memory size, etc.) always involves some level of violating software engineering norms.
One way to think of software engineering is that it's the practice of optimizing long-term developer velocity. Any practice that optimizes other factors generally does so at the expense of that. That's OK. Meta-software engineering is about knowing when long-term maintainability is the right factor to optimize for. :)
That is really only a political no-no. The issue is that if your VM takes off in popularity, some people get upset that it's a poor fit for their favorite language.
Technically, it's perfectly fine for a VM to be designed so that the semantic gap is small between it and the intended language family.
Many languages didn't support "arrow functions" AKA lambdas until fairly recently. Seeing how one would go about making large changes to the language is a good lesson to learn. One could argue that we should have known superclasses were going to be supported from the beginning, and I'd agree, hindsight is always 20:20.
Off-point. Lambdas are different, in the sense that with such functions you 1) need to think about the closure of the function 2) might need to reconsider what part of memory will be executable (you certainly don't want an executable heap). The change here is in the other direction - you remove a feature that is easily swapped with a builtin, which is implemented in terms of other already existing language features.
With this VM, we have plenty of opcode space left, so it's natural to just add another instruction for inheritance, even if it does mean that the bytecode dispatch loop doesn't fit in the instruction cache quite as well.
I'll think about this some more. I'm very hesitant to make sweeping changes to the chapter (I want nothing more in life than for the book to be done), but this would be a good place to teach readers this fundamental technique of compiling language constructs to runtime function calls.