-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement the simplest possible module system #391
Comments
After writing this, I realized that Python allows My thinking about it now is that it's a natural thing to want and a natural thing to allow. It makes things a little bit harder for anyone who expects a totally static view of the import structure — but... I think I'm fine with that. |
I realized Kernel's The This might also answer the questions about cyclic imports and immutable bindings; at least the above strategy seems to indicate that neither of those can happen in that way. |
I just found Wren's page about its module system. One refreshing thing is that (as far as I can see) you are forced to state which names you are importing, with no "just take it all" option. Part of me likes that; part of me wonders whether it would be too tedious to list things. I'm also tickled by the fact that in Wren's model, cyclic imports work. |
Just wanted to come in here to point out that I called it |
Quoting this blog post:
Blog post has a point. I want to consider it, even though I haven't come to a conclusion here. It seems that "import mechanisms" sometimes import the module itself as a namespace, and sometimes just the exported names inside of it. Languages like Python and JavaScript offer both options. Me, I'm somewhat torn between them; I would like something that's simple, with few moving parts, but which works for 95% of the use cases. |
Still torn. As fodder for deciding, I notice that A History of Clojure talks glowingly about reifying Clojure namespaces as tangible data at runtime. (Section 3.1.4.) That is nice, I guess. It maybe counts as a cheap form of "runtime reflection":
Not sure that pushes me all the way in any particular direction, but it does feel like an actual factor to consider. |
I'm 12 minutes into this talk, and realizing two things which I need to write down here:
|
Much of the thinking-out-loud in this issue is about looking ahead, and trying to arrive at a module/imports system with, let's say, nice scalability properties. The simplest possible version was nailed down already in the OP:
Short-term, I'm willing to live with a module system that does this in non-perfect ways — for example, overwriting non-exported things in the current namespace. The reason I'm willing to live with a compromise is that I think it's important to get the modules thinking going. I particularly feel this for a Some local experimentation gave some disappointing results, though:
Both of these are (presumably) blocking on the compiler. |
Back when I was thinking about "pods" — which I fear I might not have written down anywhere in this repo — I had a few more requirements. Briefly, pods would be module-like, yes, but primarily they would be independent processes, more like software components or actors. The axis of composition would still be imports/exports, but with a clear focus on lexical dependencies. (I.e. you should be able to import a thing What pods also allowed was deleting or replacing definitions. A "delete" would be a reified thing that could be exported. I don't recall if I ever made a decision about what should happen in the case where there were dependents on the deleted thing, or in the case where the thing never existed on the importing side, or was deleted by some other import. Let's assume it's possible to assign all that a consistent, non-annoying semantics. Either way, there are happy paths where none of those cases apply. A "replace" is similar to a delete; it's a reified deliberate change of something that existed before. Even here, there would be corner cases to consider. As I write this down, it strikes me I should study Racket's import system a bit more. I know it's rich and cares about fairly advanced things, like |
The README.md mentions using |
I also realize that there's a special case of "replace" which we could call "extend". In this case, the parameter list is in the obvious subtyping relationship with the original's parameter list, the return type (whether declared or not) is in the other obvious subtyping relationship with the original's, and — probably the hard part — the new version is extensionally equivalent in all the cases where it overlaps with the original. Decidability issues aside, I think that might be doable in a large fraction of cases. The "extend" case is a bit milder than a full "replace", since in some sense it's "the same" entity, only extended. |
Specifically, taking the "independent processes" at face value, a pod I state that requirement without any force of conviction. Mostly just mapping out logical consequences here. It feels that this would make pods less like modules and more like actors/components; and the function calls going between them would be (in general) more like remote procedure calls between not-necessarily compatible machines. |
Just doing some drive-by-commenting here: Bel is fundamentally a very interpreted/dynamic language, and modules are a feature that reaches towards the compiled/static end of things. Not that they clash, as such; it's more like they express different preferences. I would like the module system to favor both REPL-based, interactive, live development, while also working really well with the more offline, IDE-based, corporate style of development. Again, the two are not in opposition — it's more like they have different form factors. |
I was curious if I had written on this point in this issue. In writing the above, I seem to assume that we want the client/importing module to do the bootstrapping. But in a way, that feels a bit disconnected — a module is either written to modify the (client's) current evaluator, or it isn't. Introducing even the choice of running bootstrap after an import raises issues both of under-use (forgetting) or over-use (needlessly calling On the other hand, it's not immediately obvious to me how it would look if it was controlled from the provider/module end. Maybe as a different kind of export? The whole thing feels a little bit like declaring static methods in Java, in the sense that the method is technically declared on a whole different level. |
Related to this, I recently started out implementing a language design (with the working name Dodo) whose main feature is that function values close not just over their lexical environment, but over their local evaluator. Different modules could have different evaluators, but their functions could still call each other over a kind of inter-evaluator calling protocol. Think about how a small metacircular evaluator normally implements a call, for example this one in Ipso (and its Raku translation). We do three things:
Because this typically happens using the same evaluator throughout, we consider this to be one contiguous bit of code. But now picture that this instead is a "handshake" between two evaluators. More like messaging between two actors. (In fact, I think "messaging between actors" should be the underlying primitive here.) In that case, step 1 happens in the caller evaluator, and steps 2 and 3 happen in the callee evaluator. By necessity, the messaging happens in a kind of "step 1.5" in-between, and there is further messaging either on return of a value, or when signaling an error. (This could be handled via some kind of continuation-passing, I guess?) Dodo complicates things one step further, because it also has operatives, which means that sometimes step 1 shouldn't happen and we should send over the operands (ASTs) instead. Bel's macros work similarly, but with an extra evaluation "flourish" after getting back the result. Anyway, to tie this back to modules. I think this approach could be very clean and attractive. It sort of hides an actor system in a module/import system, and I especially like how it provides some "stability" in the sense that a function gets to evaluate in the evaluator where it was defined. That's important in a system where the evaluator can change — guaranteeing evaluator stability is similar to guaranteeing lexical scoping. Both the calling protocol and the module/imports protocol turn into points of stability; within a module/evaluator, things are allowed to change wildly, but as long as the protocols hold, they can all talk to each other. (I don't remember where I read or heard the phrase "communicating with aliens"; probably somewhere in the vicinity of Kay. But that's what's going on here. With actors, we don't get to assume anything about the way a message is received or understood; but we do have some basic guarantees about the message protocol itself.) Anyway, that's how I envision pods: module-like, actor-like entities whose innards can change wildly (because each pod controls its own evaluator), but whose external contracts and interfaces remain somewhat stable, thanks to the import/export mechanism being rooted in the static. |
Ah, I remember now. I heard it in this Bret Victor talk: The Future of Programming. He credits the idea to Licklider, who considered the problem of how two machines on the network "who just met" would be able to talk to each other.
|
Been thinking more about this one. It feels like the modules/import version of the fragile base class problem. Paraphrasing from the Wikipedia page: "seemingly safe modifications to [the source file], when [imported] by the [REPL], may cause [a sum total of definitions and behaviors that differ from just loading the file]". Maybe the right attitude to it all is that this is really a version control problem! (I just had this idea.) In other words, the source file is like an upstream, the REPL is like a local branch, importing and re-importing is like fetching from the upstream and then attempting to fast-forward cleanly, and conflicts manifest when different/incompatible updates have been made to the same definition both locally and upstream. Notably, a function definition being removed in the source file would be "tracked" in the sense that a re-import would "update" that function definition by removing it. This is definitely not as lightweight as parsing the source file and evaluating it into the REPL, but it does seem to solve the above I'm quite curious to trying that out in a prototype, at least. An obvious improvement to the idea is to support "automatic re-import" when an imported source file is saved. This would happen via some kind of file listeners, which I remember are fairly straightforward, at least on Linux/BSD. |
I just wrote about this in masak/alma#302 — in summary, Bel modules (unlike normal Bel code) need to be "scrutable", which means that they are static enough that you can expand the code until you see all the definitions, so that you can statically build an export list. The main point is that this requirement of scrutability doesn't limit you much in practice. You can still syntactically hide definitions inside of other macros, for example. |
Something quite close to the idea of pods seems to come up in Motoko's idea of canisters. These are actor-like persistent compilation units communicating with the outside world via asynchronous messages. Particularly, the idea of orthogonal persistence seemingly falls out of this. It's basically hot-swapping — that is, you get to keep a canister's internal state while upgrading its API and implementation. Persistence happens somewhere (and is enabled by the |
A stray ionized particle of a thought just hit me about this. The "module-like, actor-like" aspect is somewhat relative. Imagine you import a module B containing macros that use the uvar mechanism. Your program invokes some of those macros, which dutifully expands some code into your module, but using module B's
...unless module B magically uses the same This phenomenon is probably an aspect of something bigger. For example, I would expect all the |
Inspired by this proposal found in the Goo repo. (Edit: There's also another one right next to it.)
From what I can see for a first minimal iteration:
export
anduse
; both of them macros that expand to code that runs at runtime.(export NAME)
expands to something that pushesNAME
onto the global listmodule-exports
, which I think we'll initialize in every compilation unit tonil
.(use MODULE)
findsMODULE
in the file system, executes the whole file in a pristine environment, and then makes sure that the names from themodule-exports
in that environment are also bound in the current environment.There's something pleasingly symmetric and small about this set of requirements; there is nothing more to take away —
export
does the least bit of work to export something, anduse
(although complicated) does the least bit of work to import something.Further down the line, there will be fun complications. Let's acknowledge them but not tackle them for now:
export
anduse
on anything but the top level? Should we disallowuse
that runs conditionally, in anif
statement?use
/export
is used in practice can be "shifted left" and done completely at some static "compile time" phase? How hard should that requirement be — should we fail outright, or just give a warning?A
uses non-exported valueB
? By what mechanism should that work? What if there's also an unrelatedB
in the unit that importedA
?But enough worrying about the future — let's do a simple module system!
...oh, one last worry:
use
andexport
will end up "polluting" the regular global namespace, should there be a flag or a configuration somehow to get a "standard" Bel without them? Something like--standard
to get a guaranteed Bel-compatible global namespace. And maybe--no-modules
for this specific set.The text was updated successfully, but these errors were encountered: