Description
As julia has grown, questions about API evolution, both in Base and in packages have become more important.
One particular manifestation of this issue is that adding exports is permitted in minor versions, but
that can of course be breaking when those exports start conflicting with exports in other packages (ref #42080).
One solution that's been thrown around here and there is to version a package's exported API so that
different importers can have different views of a package's API. Such a feature could be implemented as an extension of #54654, so I figured now would be the time to flesh out what it would look like and whether we want it.
General Overview
The general idea is that exports would (optionally) declare version ranges for which
the export applies. E.g. base might do:
# atomic was added in 1.7
export vr">= 1.7": @atomic
On the flip side, imports could specify a specific version:
using Base(v"1.10")
Since 1.10
is in the version range >= 1.7
, this pulls in 1.10
.
In particular, even if the current julia version is 1.11
, any
statements tagged as export vr">= 1.11"
would not get pulled in
by the using
statement.
An export without version specification would be equivalent to a full
range, preserving current behavior. An import without version specification
would default to the minimum compat version declared in Project.toml
.
Detailed Motivation
The motivation for this feature is two-fold. First, the addition of conflicting exports
would no longer cause erros, simply because julia or a dependent package is upgraded
(addressing the concern in #42080).
The second motivation is to allow more ways of non-breaking API evolution. The key thing
here is that different dependent packages can have different views of the API surface of
a package. For example, suppose I am interested in using a new feature in a commonly used
package (let's say Graphs
for argument's sake). However, let's say one of my other dependencies
also depends on Graphs
and the functionality it uses happens to have been renamed in
the current version of Graphs
. Currently, because the view of exported symbols is
consistent for all packages, I would need to update my other dependency to the new
Graphs
version before I could use the unrelated new feature. If Graphs
instead
versioned its exported and still provided the old API, even in newer versions, I could
start using the new feature while keeping my dependency at the old (API, but not package)
version until there's a natural point to upgrade.
For completeness, I should note that a feature like this exists in UNIX dynamic linkers,
though it is not used particularly frequently outside the C library itself, because
it has poor language level-integration.
Concrete semantic proposal
- All
export
statements gain the ability to specify an opaque version range object (Base to provide a default and corresponding string macro). - All
import
/using
statements gain the ability to specify an opaque version object. - On
import
/using
, the visible symbols are precisely those for whichin(import_version, export_version_range)
(as evaluated using the visibility/world age forin
of the importing Module). - If not specified,
import_version
defaults toBase.min_compat_version(this_module, imported_module)
. This map is populated fromProject.toml
with the minimum specified compat version. - If not specified,
export
defaults todefault_export_range()
(as looked up in the exporting module). - (Optional, but probably a good idea) It is an error to try to
import
an API version larger than your minimum compat (smaller is allowed).
Concrete syntax proposal
One option would be to allow verion(ranges)s after very symbol. The simplest way to do that would be:
export foo vr">= 1.7",
bar vr">= 1.8",
bob(vr"1.9, 1.10") # Parentheses optional
import Base.foo v"1.8"
import Base.bar(v"1.8") # Parentheses optional
However, It's a bit weird to specify a different version for every symbol, so it probabl makes most sense
to group these:
export vr">= 1.7": foo, # ...
export vr">= 1.8": bar
export vr"v1.9, 1.10": bob
or on import
import Base(v"1.8"): foo, bar
though I would expect most imports to happen implicitly via the Project.toml mechanism:
import Base: foo, bar # only works if compat julia is >= 1.8 in Project.toml
I'm open to other suggestions here.
Examples of advanced use cases
While one of the primary use cases of this is just to protect new exports from causing conflicts,
this feature can be used for API evolution more broadly.
Symbol renaming/deletion
Suppose we added a new function foo
in 1.6, but in 1.11 we decided bar
was a better name for it,
but then in 1.12 we deleted it entirely from the export set.
function bar() # Formerly foo
end
export vr"1.6-1.10": bar as foo
export vr"1.11": bar
# Deleted in >= 1.12
Packages could define their own policies for how long to continue providing old API sets, but presumably,
APIs would continue being supported until at least the next major version.
API surface renaming
It is also possible to use this mechanism for changes to the API itself, e.g. addition or removal of kwargs, changes in the arguments, etc.
# Gained a mandatory `io` argument in 1.10
function hello(io::IO)
println(io, "Hello")
end
hello_legacy() = hello(stdout)
export vr"1.6-1.9": hello_legacy as hello
export vr">= 1.10": hello
That said, one does have to be a bit careful here since these are different generic functions, so this does not work for
interfaces that are expected to be extended (at least by itself).