11
$\begingroup$

As per my current understanding, there are many different ways that imports, and other related operations, are handled in different programming languages.

For example, import in Java merely tells the compiler the full path of a specific package. The package itself can be used without the import statement but the full package name is needed. However, this could 'clutter the namespace' when many different packages and if two packages have the same name one needs the full path identifier.

In Python, on the other hand, imports allow the module to be used in the code. The functions of that module cannot be used otherwise. The Python reference says that the import statement "binds the results of that search to a name in the local scope" and the imported code runs when imported.

Then, of course, there are others ways such as use in Rust.


My main questions are as follows:

  1. What are all the ways that imports can be designed in terms of semantics?
  2. What are the pros and cons of each style?
$\endgroup$
2
  • $\begingroup$ I've edited the question to make it more design specific $\endgroup$ May 18, 2023 at 13:11
  • $\begingroup$ the deeper (but quite broad) question: what different approaches are there towards modularity and module systems? $\endgroup$ May 19, 2023 at 9:22

2 Answers 2

8
$\begingroup$

As a start:

Direct file text includes

E.g. #include "foo.h"

  • simple to implement, clear parse ordering
  • Poor for dependency tracking and caching.
  • Worse performance due to more text to parse.
  • No understanding of name spacing.

Files-as-additional source dependencies

E.g. Go

  • May be extended to remote retrieval.
  • Easy to understand
  • Only compiles what is needed.
  • Cyclic dependency must be handled.
  • No understanding of name spacing.

Visibility module imports with ad hoc path access

E.g Java imports.

  • Easy to use
  • Name spacing
  • All files must be parsed.
  • Implicitly includes all.
  • The dependencies cannot solely be resolved by looking at imports.

Module directory imports

Loading files from name using a known file scheme. E.g. "import foo.bar" loads file project/src/foo/bar.abc

  • Easy to use
  • Name spacing
  • May be used to only compile what is needed
  • Strict path scheme may interact poorly with tools and generic IDEs
  • Harder to split and merge code between files.

File agnostic module imports

Parsing all files, assembling modules prior to import resolution. (Similar to Java but without ad hoc path access)

  • Easy to use
  • Name spacing
  • All files must be parsed.

How these are then implemented depends a lot on the overall language semantics and must be determined from case to case.

$\endgroup$
5
  • 1
    $\begingroup$ @MaxHeiber the case where multiple modules are defined in a single file is covered by "File agnostic module imports". That said, the above isn't an exhaustive list. $\endgroup$
    – Nuoji
    May 18, 2023 at 9:10
  • $\begingroup$ Thanks @Nuoji, deleted my comment. But note that for Rust, not all files are parsed. The module graph is walked root-to-leaves, so (even for .rs files), if they aren't referenced they aren't pared iuc. But understoood that the list isn't exhaustive. $\endgroup$
    – Max Heiber
    May 19, 2023 at 10:57
  • $\begingroup$ I hope people add more examples even though this answer was accepted. There are a lot of different schemes, with each suited better for some languages and worse for others, so investigating many languages is useful when designing imports. $\endgroup$
    – Nuoji
    May 20, 2023 at 12:34
  • 1
    $\begingroup$ It's worth noting that the approach to module imports should, in general, be tied to how you would interface this with make/build systems. They interact in... interesting ways. $\endgroup$
    – Pseudonym
    Jun 8, 2023 at 0:17
  • $\begingroup$ Since your "Module directory imports" example seems to mimic Python, it may be worth noting that part of the point is that there isn’t necessarily just one corresponding path or even a path at all. In the case of Python, packages can be transparently loaded from inside archives and so/dll files by default, and custom loaders can fetch code in practically arbitrary ways. $\endgroup$ Aug 24, 2023 at 6:37
9
$\begingroup$

Broadly, there are two kinds of importing:

  • Import declarations, which make other modules or their exported members available by name in the current scope;
  • Import statements, which execute the code of another module, including its top-level declarations and any top-level statements it might have, and then bind that module or its exported members to some name or names in the current scope.

The distinction is that import statements may have side-effects, whereas declarations do not. Referring to your examples, Java and Rust have import declarations whereas Python has import statements.

There are other ways of handling modules, particularly "includes" (which dump the source code from the included file directly or somewhat directly into the current one), but I won't address those in this answer as it's already quite long. These are normally not called "imports".


Import declarations

Import declarations tend to be used in compiled languages, especially languages where statements cannot occur at the top-level of a compilation unit. These are generally the languages where you declare a "main" function as the program's entry point.

They have no side-effects because the modules they import don't have statements at the top-level, so there is nothing to execute just from "importing" those modules. The order of import declarations typically doesn't matter, and they are only used at compile-time; they have no effect at runtime, after the various modules have already been compiled and linked together into a single binary.

Import declarations may be implemented in a compiler by doing two passes over each module ─ one to find the names and types/signatures of all exported members of each module, and then another to resolve the names used in the imports of each module.


Import statements

Import statements tend to be used in interpreted, dynamic languages, especially languages where declarations of functions, classes, etc. are also statements. These are generally the languages where a program's entry point is just the top of the source file that the interpreter is executing.

They can have side-effects because the modules they import can have statements at the top-level, so the order of import statements can matter. Import statements in dynamic languages generally load the imported modules from the filesystem (or otherwise) at runtime, unless the module has already been loaded and cached by an earlier import of the same module. This is why they only appear in interpreted languages ─ in a compiled and linked binary, the source files (and hence, the separate modules) simply wouldn't exist at runtime.

Import statements will typically be implemented by an interpreter loading, parsing and evaluating the imported source file at runtime, basically invoking the interpreter itself recursively. Once the recursive invocation of the interpreter completes, the exported members may be bound as properties of an object representing the module, and then that object is bound to the name associated with the module, e.g. as in a simple import statement like import foo. Alternatively, an import statement like from foo import bar, baz may destructure this object in order to bind the exported members to simple names.

The loaded modules (and their exported members) are typically also cached, so that if they are imported multiple times, their side-effects only trigger once, and later imports don't have to parse and evaluate the whole source file each time. Additionally, an "empty" or "busy" placeholder may be added to the cache for a module while it is currently being loaded, in order to detect circular imports, which could otherwise cause unbounded recursion. If a circular import is detected, this may either be handled by throwing a runtime error, or just using the "empty" object in the cache instead of the unavailable result.

$\endgroup$
4
  • $\begingroup$ very useful distinction! considering how annoying side-effect can be (e.g. when trying to treeshake JS imports), would you say that "import statements" are just a historic accident of how scripting languages evolved, or are there benefits to doing things that way? $\endgroup$
    – mb21
    Feb 1 at 9:49
  • 1
    $\begingroup$ @mb21 Neither. The fact that modules can have side-effects is due to a fundamental feature of scripting languages: statements are allowed at the top-level. So it's not an accident, it's a necessary consequence. $\endgroup$
    – kaya3
    Feb 1 at 14:57
  • $\begingroup$ right... but modules could be special in not allowing top-level-statements, while normal script files (without any exports, only imports) could have top-level statements. $\endgroup$
    – mb21
    Feb 2 at 11:18
  • $\begingroup$ @mb21 I suppose if there is a distinction between module source files and script source files then you could do that; I don't know of any languages which work that way ─ e.g. Javascript has .js and .mjs extensions but I think modules are allowed to be .js, and they can have top-level statements anyway ─ but you could have a language like that, sure. I'm not sure if I would call it a scripting language any more, though, and people might use it like a non-scripting language and just have a "runner" script which does nothing but import and run a main() function. $\endgroup$
    – kaya3
    Feb 5 at 17:49

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .