In our most recent meeting, and actually a few meetings, we discussed the Private Type in Public API rules. We’re in a bit of a frustrating place with these rules:
- Many people are confused as to their intention:
- Their intention is to ensure that if you have a private struct, then nobody outside of the module can get their hands on an instance of that struct, and nor can they name the type. This implies that methods defined on that private struct (including methods from public traits like
Clone
which don’t have privacy protections) cannot be invoked except for within the module that defined it. This is a useful guarnatee for unsafe code. - The purpose of the rules is not to guarantee that you have properly exported all the things in your API that end-users might want. The initial RFC had that goal, but we scaled it back (perhaps wrongly so).
- Their intention is to ensure that if you have a private struct, then nobody outside of the module can get their hands on an instance of that struct, and nor can they name the type. This implies that methods defined on that private struct (including methods from public traits like
- The initial implementation however was riddled with bugs.
- @petrochenkov did excellent work fixing those bugs, and we now give future compatibility lint warnings for many add’l cases.
- The expectation was to change those warnings to errors. But when we tried it, we found that a lot of extant code would break.
- There are also some remaining holes, notably around traits and inference.
So basically we have to choose between:
- Abandon the rules altogether; I think they play a useful role, though, and am reluctant to do that.
- Make a minimal set of rules that leave to hard errors and enforce those. We can supply lint warnings for other cases. This would have minimal regressions for existing sources.
- Enforce the rules as designed. This would regress a fair number of crates.
I’d particularly like to get @petrochenkov’s opinion here. @eddyb can probably supply facts as to the measurements we did about regressions.
What are the guarantees?
Before we do anything, I want to talk a bit about what kinds of properties we are trying to guarantee with these rules. Ideally, we would say that if you have a private struct X, then you know that the only place that can call methods on X or access the fields of X is in your module. But that’s actually not quite true (and you don’t really want that). Consider, for example, that you might want to create a Vec<X>
, and then that this Vec
will drop values of type X
, which is potentially invoking a public API (the drop()
method). Similarly, an Arc<X>
might invoke clone()
on X
.
So the real property is something more like this: if you have a private struct X, then you control who can access values of type X. Moreover, when you let a value of type X out of your control (i.e., by passing it to another fn as argument), you know that code outside your module can only access X through members of public traits.
Here are some examples of what I mean. With the originally intended rules, you will find that you can’t just naively return a value of type X:
mod foo {
struct P;
pub fn f() -> P { } // illegal: return private type from public fn
}
But you could pass a value to a generic function outside the current module, and this function might use trait members:
fn clone<T: Clone>(t: &T) -> T {
// this call to clone is not in the module `foo`, but it still accesses
// a value of type `foo::P` (since T = foo::P)
t.clone()
}
mod foo {
#[derive(Clone)]
struct P;
pub fn f() { clone(&P); }
}
And you can package up these values as trait objects and they can escape arbitrary far:
trait Object { }
fn to_object<T: Object>(b: Box<T>) -> Box<Object> { b }
mod foo {
struct P;
impl Object for P { }
pub fn f() -> Box<Object> { to_object(Box::new(P)) }
}
But in both of these examples of leaking, access was always mediated by a public trait.
The rules we intended
The current privacy rules are enforced at the definition site, primarily. This means that, for example, it is intended to be an error to define a public method that returns a private type. This way, we know that callers of that public method (which may, in general, be in other modules or crates) can’t get their hands on the private type. However, it could well happen that in practice all callers of the public method are within the current crate. In that case, the fact that it returns a private type isn’t really a problem.
This scenario actually arises fairly often in multi-module setups similar to those I described in my comment on the pub(restricted)
RFC. For example, consider this case:
mod foo {
struct PrivateToFoo;
mod bar {
pub fn private_to_foo() -> PrivateToFoo { ... }
}
}
Here, the function foo::bar::private_to_foo()
is public and returns a private type. But because that fn is defined in a private module, it will never actually be used outside of foo
. Now, using pub-restricted, private_to_foo()
would more properly be declared as pub(foo) fn private_to_foo()
, but that is a new feature and there is a lot of existing code that is not using it (and which is currently getting future-compatibility warnings). Under the “error at use site” rules, this code would be accepted, because, while the definition could be used wrong, it isn’t actually being caller from outside of foo
.
An alternative approach
@eddyb has been contemplating a more limited approach. After all, if what we want is to guarantee that values of private types are not used outside the module, we can make that an error by making it an error to have an expression whose type winds up being a private struct. That is, we don’t make it illegal to have a public function that might return a private value, but rather we make it an error to call that function from outside the module. So that means the previous example would be legal, since foo::bar::private_to_foo()
is never called from outside of foo
(and, in fact, is inaccessible outside of foo
).
Note that we could still give a lint warning on foo::bar::private_to_foo()
– but just a normal lint, warning you that you may have intended for the PrivateToFoo
to be public, or else that you may have wanted to declare the function to pub(foo)
and not pub
.
Here is a variation of the previous example that would get an error under the use site rules. In this version, the module bar
is declared as pub
, and private_to_foo()
is used from main()
. This gets an error both today and in the proposed rules, but the site of the error is different:
mod foo {
struct PrivateToFoo;
pub mod bar {
pub fn private_to_foo() -> PrivateToFoo { ... }
impl PrivateToFoo { pub fn foo(&self) { } }
}
}
fn main() {
let x = foo::private_to_foo(); // <-- Error here! Inferred type of `x` is illegal.
}
The problem here is that you may have intended PrivateToFoo
to actually be a public struct (ok, you probably would have picked a different name in that case, but bear with me). But you won’t know that there is a problem (at least based on the errors alone) until you try to use your library. That is why we’d still prefer to give a lint in this case suggesting that the public/private declarations are misaligned.
The catch: associated traits
It’s not enough to check the types of expressions for errors. We also want to prevent people from naming a private type from outside the module (and hence keep them from invoking associated functions on that type, like PrivateToFoo::new
, and so forth). These names can result from various sources, but one of them is by normalizing associated types. Consider a case like this:
mod foo {
pub struct Public;
struct Private;
impl Iterator for Public {
type Item = Private;
}
}
Now any code could write <foo::Public as Iterator>::Item
to name the type Private
. This is problematic. So we would still want to keep the existing rule that a private type can only appear in an associated item if at least one of the input types (i.e., Public
here) is private.
(Similarly we have to close the existing hole that allows trait inference to sometimes infer a use of a private type the user could not have written manually.)
Now that we have to enforce this error in the impl, because otherwise generic code might name the associated type without knowing that it normalizes into a private type:
fn f<T: Iterator>() {
do_something::<T::Item>();
}
Here I could call f::<foo::Public>
and it would wind up using Public::Item
which is Private
.
Connection between pub use
and associated type paths
One can view an impl with an associated type (as in the previous section) as defining a new “name” for a type. The type foo::Private
can now also be named as Public::Item
– but we make it illegal to define such a path. But there is another way to define an “alias” for an existing path: by using pub use
. For consistency, then, we might consider making it illegal to pub use
a non-public item:
mod foo {
pub struct Public;
struct Private;
pub use self::Private; // Error here at def'n site?
}
Conclusion
Sorry, I’m finding it hard to organize my thoughts cleanly on this topic. But let me conclude by saying this. The way I see it, the first choice is if we will enforce “strong privacy” guarantees of the kind I described here. I still think we should – though there might be an argument saying it’s not worth the effort. But that would also allow oddities like the ability to infer a type that you would have been illegal for you to write explicitly (i.e., when a public function has a private return type).
Presuming we want to keep that general property, then we have to make some practical choices. I think all things being equal the definition-side enforcement is better, but if we wish to avoid regressions, then it seems pretty acceptable to me do the strict enforcement at use-site, but supply lints at the definition site.
In fact, this may be the more ergonomic option: it means that you can get away with slightly inconsistent public/private declarations (such as public functions that return private types) so long as you don’t use private types from the wrong places (that is the hard limit). Then you can come back and fix the lints once you’ve got your stuff setup.