Recently, there have been a number of proposals for expanding out the set of
platform-, architecture-, or configuration-specific APIs, including:
We need to discuss our overall vision for accommodating these kinds of APIs,
because the vision worked out pre-1.0 is not broad enough to accommodate them.
The status quo
We previously addressed the question of platform-specific APIs in std as part
of the IO stabilization effort. The design was built to strongly emphasize
cross-platforms APIs, while providing relatively easy hooks into
platform-specific ones. In more detail, we proposed:
-
A cross-platform core API (see
the RFC). In
normal std modules, APIs should only be exposed if they are supported on
"all" platforms. They should follow Rust conventions (rather than the
conventions of any particular platform) and should never directly expose the
underlying OS representation. In principle, this makes the "default"
experience of programming against std result in highly portable
applications. In practice, even the “cross-platform” APIs exhibit some
behavioral differences across platforms on edge cases; we’ve worked hard to
keep that set small.
-
Platform-specific extensions (see
the RFC).
Another bedrock principle of std is that it should be possible to access
system services directly, with no overhead. A basic way we do this in std is
to provide “lowering” APIs, which are methods on std abstractions that
expose the underlying platform-specific data (e.g. file descriptors). These
lowering APIs mean you can build external crates that bind platform-specific
functionality and provide it as a direct extension to std abstractions. For
the most important extensions, we provide direct bindings in std itself.
There’s one additional, very important piece: in std today, all
platform-specific APIs are cordoned off into submodules of std::os, which has
submodules like windows, unix, macos, and linux (note that the last
three form an implicit hierarchy). The upshot is that when you’re doing
something platform-specific in std, there’s a clear signal: you will have use std::os::some_platform as a way of explicitly “opting in”.
The platform-specific APIs generally work through extension traits
(e.g. MetadataExt),
which means that once imported, they’re usable just as ergonomically as the
platform-specific APIs. Each platform provides a prelude module to make the
imports themselves very easy to do.
The problem
All in all, the current systems works well for one particular platform
distinction: the operating system. Since we can form a rough hierarchy of
supported OSes (e.g., unix at top level, linux underneath, and conceivably
specific kernel versions beneath that), it’s always clear where an OS-specific
API should live: as high in the hierarchy as possible.
The approach starts to fall apart, however, once you bring in other factors:
-
Architecture, which doesn’t tend to form clean hierarchies in terms of
support for e.g. specific instructions. It’s very difficult to provide a
clear module structure that delineates architectural capabilities.
-
Configuration, which applies to both the Rust side (e.g. abort-on-panic)
and the system side (e.g. C types can shift depending on how the OS is
compiled). Tends to be cross-cutting and also not hierarchically organized.
-
External crates, which usually don’t follow the std::os pattern and can
thus be hard to gauge for platform compatibility. Moreover, there are
additional divisions like no_std which impose further compatibility restrictions.
While it’s relatively easy to grep for std::os::linux or the like, it clearly
doesn’t give the whole story, even for OS-specific APIs, let alone for
non-hierarchical concerns.
Design goals
To ease discussion, I’ll use the term scenario to describe any collection
of OS, platform, architecture and configuration options.
Let’s take a step back and re-asses what we want in our story for
scenario-dependent APIs. Assume for the moment that we can identify a class of
"mainstream scenarios", which encompass platform, architecture, and
configuration assumptions.
Here are some potential goals for a broader design:
-
By “default”, Rust code should be compatible with “mainstream scenarios”.
- Ideally, it’s also easy to gauge for even broader compatibility
requirements, without literally having to compile for a number of platforms.
- Ideally, compatibility constraints could be easily imposed/checked across all crates being used, not just
std
-
Rust should offer best-case ergonomics for “mainstream scenarios”.
- This means APIs should be particularly easy to find and use in the
mainstream case.
- Ideally, of course, ergonomics are good across the board.
-
Allow for arbitrary, non-hierarchical sub- and super-setting of “mainstream scenario” APIs.
- Ideally, with very clean/simple organization, so you always know where to
look for a given API, whether mainstream or not.
A possible approach: lints
So, here’s an idea: use the lint system to ferret out unintentional
dependencies on the current compilation target, instead of using module
organization as we do today. In more detail:
-
Do away with the std::os hierarchy; instead, put APIs in their “natural place”. So, for example, instead of things like UnixStream appearing in
std::os::unix::net,
they would go directly into std::net. This is similar to the approach we
ended up taking for the
new atomic operations – they
landed directly in std::sync::atomic rather than some architecture-specific
module hierarchy. In general, we’d have a single, unified std hierarchy, and
encourage other crates to do the same.
-
APIs can be marked with the “scenario requirements” they impose. So
UnixStream would be marked with unix, and AtomicU8 might be marked with
a direct requirement like atomic_u8. In general, an API would be marked with
a set of such requirements; if the set is empty, the API is truly “cross-scenario”.
-
Each crate can specify its desired “compatibility targets”. By default,
the target will be “mainstream scenarios”. This information drives the lint,
which will trigger in any use of an API that happens to be available in the
given compilation target, but not the desired compatibility target (which
is usually much broader).
-
Compatibility targets group together scenario requirements in arbitrary, non-hierarchical ways.
That is, when you talk about the compatibility
requirements for an app or library, you don’t usually want to talk about a
combination like unix + atomic_u8 but rather unix + (x86_64 | aarch64). The point is that these groupings and requirements can have
arbitrary complex, non-hierarchical relationships, giving much greater
flexibility than the existing os module hierarchy does.
The lint approach is attractive for a few key reasons:
- It allows for the most natural/ergonomic placement of all APIs.
- It provides a very clear way for a crate to specify its desired compatibility
targets, and check for those in the course of a single compile.
- It works across the whole ecosystem, not just
std.
I want to expand briefly on the last point. I expect the lint by default to
assume that the scenario requirements for a given fn definition to be exactly
the ones of the functions it invokes. Normally, this kind of thing doesn’t work
well because of things like closures, but if the only way to make a given fn
behave in a platform-specific way is to pass it a platform-specific closure,
then the fn isn’t really platform-specific after all.
If we’re careful about how we put the lint together, it will automatically
produce the right results for an arbitrary crate based on how that crate uses
others (including std). We’d also need a way to say that a given fn imposes
fewer constraints than it might seem, e.g.:
// tell the lint there are no scenario requirements, because we'll be using
// cfg internally to dispatch to the appropriate platform-specific code but
// will cover all platforms in doing so.
#[scenario_requirements()]
pub fn cross_platform(...) {
platform_specific_helper(...)
}
#[cfg(unix)]
fn platform_specific_helper(...) { ... }
#[cfg(windows)]
fn platform_specific_helper(...) { ... }
Assuming we’re interested in the lint approach, there are a lot of details to
figure out here, including:
- What constitutes a “mainstream scenario”? Can that change over time?
- What is a sensible set of scenario requirements and targets?
- What is the right way to specify all this information?
- How can we orient the design so that the lint gets things right by default as
much as possible?
I’d love to hear people’s thoughts on the goals, the overall suggestion, and
any of the details!