Recently, there have been a number of proposals for expanding out the set of platform-, architecture-, or configuration-specific APIs, including:
- More atomics
- Optional float support
- SIMD
- Deeper platform APIs, e.g. UnixSockets
- Android release compatibility
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 againststd
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 instd
is to provide âloweringâ APIs, which are methods onstd
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 tostd
abstractions. For the most important extensions, we provide direct bindings instd
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 likeno_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 likeUnixStream
appearing instd::os::unix::net
, they would go directly intostd::net
. This is similar to the approach we ended up taking for the new atomic operations â they landed directly instd::sync::atomic
rather than some architecture-specific module hierarchy. In general, weâd have a single, unifiedstd
hierarchy, and encourage other crates to do the same. -
APIs can be marked with the âscenario requirementsâ they impose. So
UnixStream
would be marked withunix
, andAtomicU8
might be marked with a direct requirement likeatomic_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 ratherunix + (x86_64 | aarch64)
. The point is that these groupings and requirements can have arbitrary complex, non-hierarchical relationships, giving much greater flexibility than the existingos
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!