[lang-team-minutes] private-in-public rules

Good question, and something that @pnkfelix and I discussed for quite some time. =) It's a question of consistency and guarantees. Let's start with this setup:

trait Factory {
    type Element;
    fn make() -> Self::Element;
}

mod factory {
    struct Priv;
    impl Drop for Priv { ... }

    pub struct Pub;

    impl Factory for Pub {
        type Element = Priv; // illegal now, but we're talking about making it legal
        fn make() -> Priv { Priv }
    }
}

First, consistency. Imagine I write this code (outside of factory):

use factory::Pub;
fn foo() {
    let x: Pub::Element = Pub::make_element();
}

If we are checking the post-inference types, this will be an error. It will be an error because we normalized it to factory::Priv and you are not supposed to have access to factory::Priv. (If you imagine that we're going to distinguish between a type that you wrote in normalized vs unnormalized forms, then the whole idea of checking types post-inference starts to fall apart, since normalization is no longer something we can do at all in the type checker etc, and I'm not willing to go there.) But if I were to write this function differently, it becomes legal:

use factory::Pub;
fn foo() {
    bar::<Pub>();
}

fn bar<T: Factory>() {
    let x: T::Elem = T::make_elem();
}

Now it is legal because bar doesn't know what T is, so we can't normalize the type. I find this inconsistency surprising, but moreover, it means that now bar() has gained access to a factory::Priv. That in and of itself is not so surprising -- I mentioned in the beginning that factory could leak an instance of its private type outside of the dynamic extent of one of its fns, but hidden in an object type. But this is different: we leaked the raw type.

In other words, it's consistency again. If I wrote this function in factory, which is the same as Pub::Factory::make_elem, but not going through a trait:

mod factory {
    fn make_priv() -> Priv { Priv }
}

Then I wouldn't be able to call it from outside factory, right?

I contend that if the rules are this inconsistent, they will be easily misunderstood. If you have a public function that returns a private type -- whether it be declared on a trait or as a free fn -- I feel like the guarantees, such as they are, should be the same.

I'm still struggling to state those guarantees in a precise way. My first stab was that private things could only be manipulated from inside factory. But that's not right (and couldn't be) because factory can invoke generic functions like fn bar<T: Factory>(). Then I thought that such a fn can only be invoked from within the dynamic extent of code in factory, but that's not quite right, because factory could launch a thread, and it could return an object. But what both of those have in common is that factory started out in control of the private value (i.e., it created it) and it leaked it through an object or by calling a generic function from another thread. Those seem "intentional" to me -- i.e., if I am tracing through the code in factory, I will see (or could see) that it happens. In contrast, with the code snippets above, where foo calls bar::<factory::Pub>(), if I just read the code in factory, I cannot know that a value of Priv will get created and dropped from other code -- it's outside of my purview entirely.

Now, you might argue that factory may have created the foreign alias intentionally by defining the impl of Factory:

mod factory {
    ...
    impl Factory for Pub {
        type Elem = Priv; // <-- here
        ...
    }
}

In other words, that's like defining a publicly accessible alias. But that violates the principle we've been striving for, that I should only have to look at where Priv is declared (at the struct Priv declaration, in other words) to know definitively it is a "private" or "public" type. I shouldn't have to look at the transitive closure of pub use and impl items to figure it out.

2 Likes