EDIT There is a second version of this proposal down in the comments. https://gist.github.com/445adc206d51a038de4a#gistcomment-359858
I really like Isaac's module proposal. Having recently designed a module system for the luvit project (node ported to lua), I've thought a bit about this.
I think we can set a few minimal rules and reduce a ton of edge cases. One in particular is cyclic dependencies. I don't think they are worth the pain and should simply be disallowed.
To help understand things, we should make a distinction between dependencies and peers. In a large application or package there are several files containing code. Some files depend on things defined in other files before they can start. Simple dependencies that block startup can't be cyclic or you'll have a deadlock at startup. So the other type of dependency is wanting to reference an api or function from another file during runtime.
The basic syntax I propose is like the one Isaac showed (if I understand it correctly). There are two new keywords, import
and export
. The import
keyword must be followed by a string literal that is the identifier for the file you want to import. The export
keyword is like return
and yield
in that it sends a value. It does not affect control-flow, but it does set the current file's export value.
Consider lib/add.js
:
// lib/add.js
function add(a, b) {
return a + b;
}
export add;
And then we have a peer file that depends on this:
// lib/usesAdd.js
var add = import "./add.js";
var result = add(2, 3);
Now suppose we wanted to write a math package that contains add as well as other functions.
// math.js
export {
add: import "./lib/add.js",
multiply: import "./lib/multiply.js",
// ...
};
Here is where this gets tricky. Suppose that we want to implement multiplication using addition and a loop. So our multiplication library will need to depend on add. I can't depend on the main app because that depends on it. So it has to depend on add directly.
// lib/multiply.js
var add = import "./add.js";
function multiply(a, b);
var product = 0;
for (var i = 0; i < b; i++) {
product = add(product, a);
}
return product;
}
But not we learn later on that add needs to access multiply for some reason. This happens all the time in real applications. How would this be required?
I really want dependencies with import and export to be expressible as a DAG (Directed Acyclic Graph). There is a clear order for loading the executing the files. There are two reasons for this. One, it makes a lot of nasty edge cases with current module systems simply go away. And second, JavaScript is single threaded. Even with the exports.foo = bar
syntax in node and CommonJS, the dependencies have to be executed in some serial order. This may surprise people when you're allowed to require some module, but it's methods aren't populated yet because you were executed before it was. This would be a leaky abstraction.
So then how would I solve this dilemma using acyclic dependencies? I would handle it at the application level where I control the logic and know what to expect.
I would rewrite math.js
as follows:
// math.js
var math = {};
export math;
math.add = (import "./lib/add.js")(math);
math.multiply = (import "./lib/multiply.js")(math);
Then in each module I would export a function that accepts the math
namespace.
// lib/add.js
export function (math) {
return function (a, b) {
// ... might use math.multiply
};
};
There are several other techniques that come to mind that I could employ for other use cases. The point is that using the basic primitives that would be provided by the language, I can organize my app however I want. My code would use the same style in the browser or in node. The browser could pull my code on demand as it's needed or a server-side compiler could statically analyze the imports and generate a single js file that the browser then reads. All JS code everywhere would be written using the same simple module syntax, but without imposing huge constraints on the runtime or providing leaky abstractions to applications.
Disallowing cyclic dependencies between modules is a mistake. At the level of deployed libraries, it makes sense not to have cycles, which you can enforce for example by dependency tracking in a package management system like NPM. But within an application or library, it's crucial to allow people to divide up the internal modules however they see fit. Disallowing cycles forces programmers to restructure their programs in painful ways.
You're making the programmer do the work for you in the name of simplifying the job of the language designer. That's the wrong way around. You should not force programmers to implement their own "tie-the-knot" pattern every time they have a cyclic dependency. I've been there in other languages, and it's painful.
Here's another way to look at it. A module system should stay out of your way as your program grows. It should impose a minimum of costs to splitting up your program into modules however you think is appropriate. When you take code in one module and split it into several, it should continue to work. JS already allows mutual recursion within a single scope, but it's initialized in a linear order. So typically if you have mutual recursion, you need to make sure not to invoke it until everything's been initialized. If you want to enforce an order of initialization, it's much simpler to just access the cyclic subgraph only through some single entry point. In your example, that would mean that other parts of the app would only access math. In the ES6 module system, add and multiply could be mutually recursive. The math module's import order would ensure that both add and multiply were initialized before math is initialized. There's still a deterministic order of initialization (and there's no deadlock problem).
If we ban cyclic dependencies, then when you want to pull apart mutually recursive code into separate modules, you're forced to use these painful patterns, and in practice you just end up avoiding it and keeping everything in one big module. In other words, the incentives prevent you from using modules. The language gets in your way.
Whereas, with cyclic dependencies, you can simply separate your app into separate modules without having to worry about whether the language will yell at you and force you to restructure everything. And if you want to ensure that they're loaded in a specific order, you can easily use a facade module like math. This is lighter weight than restructuring your modules and all their dependencies.