-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Abstract output type parameters #1305
Conversation
I like the general direction of this RFC. It seems to address the OIBIT leaking and the conditional trait bound problem in a comparatively simple and orthogonal way. It's somewhat complex, of course, but all the parts are (or at least seem) immediately obvious to me and I certainly don't consider myself a type system guru. Regarding the alternative of having only Here's my current position (subject to me changing my mind) on the other alternatives:
|
How about re-using the impl syntax?
This would allow you to customize which traits to export depending on type parameters as well. |
Like @rkruppe, I like the general direction, but this is a lot of stuff. For lack of any larger or more constructive ideas at the moment, various notes and nits:
|
This proposal seems a little too complicated for my liking, even though I really would like the functionality it allows. I rather like @glaebhoerl’s idea of using fn foo<T>(t: T)<U> -> U
where U: Clone if T: Clone
{ t } becomes abst type Foo<T>; // this is resolved as equivalent to `T` because of `foo`’s body
impl<T: Clone> Clone for Foo<T> {}
fn foo<T>(t: T) -> Foo<T> { t } This does however seem like it could be very difficult for the type checker to handle, but I don’t know anywhere near enough about the type checker/inferer to say for certain. I also have problems with the mod foo { struct T; }
fn foo()<T: Clone> -> T where T: Mul { 42 }
let x: foo::T; // which `foo` are we referring to here? |
signature, it may be declared directly inline with the syntax | ||
`type IDENT[: BOUNDS]`. | ||
Example: | ||
```rust |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code block (as well as the one in example 2) is rendering strangely, maybe add a new line and remove the space before the ticks?
Thanks for taking the time to write this up @Kimundi :) A few things that came to mind:
// Could this:
abstract type Foo: Clone = ();
// possibly just be this?
abstract type Foo: Clone;
In general I really like where this is going 😸 The ability to talk about the anonymised return types in the same way that we can talk about existing input generics (using where clauses and taking advantage of the type param style) is a huge plus for both consistency and expressiveness 👍 |
Keep in mind that "parameter" is quite wrong as parameters are input by definition. |
I only just realized that closures don't fit quite as neatly into this picture as iterator adapters do. It's all very nice that we can define abstract type aliases, but if we need to name the underlying type, then that capability can't be used for closure types. If general output types are omitted and only abstract type aliases remain (which is an option I really want to have available), then you can't have an alias for an unboxed closure because you can't name it. The This can be avoided either by using the output type machinery, or by following the idea of @glaebhoerl (declaring abstract types without naming the underlying type). But as soon as things depend on input type (and lifetime) parameters, it gets complicated again: fn make_factory<T: Clone>(x: T) -> impl Fn() -> T {
move || x.clone()
} One would have to define an abstract alias Edit: Oh wait. If output types are really associated types, then of course |
One concern I have is how simple it would be for a library user to use an abstract output type defined in a library in their own function signature. As a particular example I have parser-combinators; they usually encourage users of the library to write small composable functions returning parsers (and not a lot of inline closures or similar). The If the usage of an already defined abstract output type in a function signature will require complex and/or lots of code, then this will increase the noise and complexity of any code which encourages users to define composable functions all unified under the same abstract return type. |
UpdateHeads up, @eddyb informed me on IRC that this needs HKT support in the compiler (though its not explicitly needed on the language level apart from what is used in this proposal) The issue is, right now if you have something like trait Foo<T> {
fn bar<U>();
} then thats actually internally closer to fn Foo_bar<Self, T, U>() { /* body determined by paramters */ } rather than trait Foo<T> {
type bar<U>: FnOnce();
} which would be HKT, and needed to access the output types proposed by this RFC. Replies
I feel like thats getting verbose enough to not be that different from defining a newtype, with or without a impl delegation mechanism.
@P1start
@rkruppe You don't name the actual type with output type paramters, so using the non- fn foo()<T: Display> -> T { 0_i32 }
fn bar()<U: Fn()> -> U { move || println!("hello {}", foo()) }
abstract V: Fn() = bar::U;
let f: V = bar(); |
Makes sense. Still, if these extensions are as controversial today as they were during the discussion of the original RFC, I would drop them in a heartbeat.
Yes, I realized that afterwards, see the edit. It's mostly a problem for various hypothetical simplifications that I liked because they would remove most of the complex machinery of this RFC. That said, @eddyb has curbed my enthusiasm soundly. I don't know what he thinks of this RFC and don't want to imply anything, but he certainly pointed out several problems that I completely missed. So not only does this RFC look less rosy than it used to, I'm also questioning my judgement now. The HKT problem was already described by @Kimundi above. Furthermore, most variations along the lines of |
If I understand your concern right: a function returning an unboxed, abstract closure type cannot return different closure types with different upvars, because then the result could not be unboxed. So the set of upvars (and corresponding type variables) are fixed, and the abstract return type may only depend on the function's own generic parameters. So using only
This has two drawbacks: you need to repeat the type/lifetime parameter list on both the
Front door. Module-scoped existential types are what @Kimundi Could you show a concrete example of where you'd need HKTs internally - the surface-level "abstract output types" code along with the internal HKT-implicating code it'd need to be lowered to? I'm having a bit of trouble drawing the connection on my own. |
@glaebhoerl I think you do not understand, which may be because my post went through at least three edits and was at all times a rambling mess. Here is how the closure in struct Closure<T, U0> {
x: U0
}
impl<T> Fn<(), Output=T> for Closure<T, T> {
fn call(&self) -> T {
self.x.clone()
}
} Note that there are two type parameters and, outside of the impl, there is no causal relationship that would allow us to say "actually there's only one type parameter". The desugared struct has parameters for all type and lifetime parameters of its containing function, and furthermore one type parameter per upvar. And there can be almost any relationship among those, consider for example this function: fn foo<I: Iterator>(i: I, msg: &str) -> ??? {
let first = i.first();
move || {
first.unwrap_or_else(|| panic!(msg))
}
} This returns some type that implements Perhaps one could define closure desugaring in enough detail that it's possible to reliably express "abstract over" closure types, by just spelling out all lifetimes and throwing all lifetime and type parameters and all upvar types at the abstract type. However, that would be excessively difficult to use, easy to get wrong, and very verbose. Not to mention possibly constrain future evolution of the desugaring. |
@glaebhoerl I have to add that this is all under the assumption of parametricity, i.e. that one generic |
@rkruppe It's certainly possible that I don't understand. :) Considering your second example, suppose we write:
Given this, why couldn't the compiler infer:
? As for the complexity of the Separately, it's not quite clear to me why the anonymous closure types need to have a type parameter for each of their upvars... considering for instance a function |
I don't know if it absolutely physically couldn't. But I, personally, don't see a general procedure that gives predictable results and always works. Perhaps such a procedure exists, then my point is moot (or rather, the problem becomes a simple complexity trade off).
But this is how the desugaring currently works. A comment goes into some detail as to why. IIUC, it's not strictly necessary but significantly simplifies the implementation. |
I'm not a master of type inference either. We should probably wait for someone like @nikomatsakis to comment.
Thanks. That comment gives a persuasive motivation (chiefly, function-internal lifetimes). That said, it seems to me that a function couldn't ever return a closure object which actually does reference lifetimes internal to that function, because |
Readability concern -- instead of fn foo<T, U: A>()<V, W: B> -> X<V, W> where T: C, V: D { ... } how about: type V, W: B in
fn foo<T, U: A>() -> X<V, W> where T: C, V: D { ... } (inspired by |
@critiqjo That's a neat syntax for existentials, I like it! One caveat, though, it should somehow come after generics if those existentials can capture the generics, e.g: for<T> type V, W: B in
fn foo<U: A>() -> X<V, W> where T: C, V: D { ... } This would mean V and W can capture (depend on) T, but not U. |
What is the state of this? |
How would this interact with object safety? |
@apasel422 Where would there be any interaction? Object safety is about actually being able to construct a vtable, while anything like |
@RalfJung we only have plans to allow specialisaton of impls, not functions,so I don't think this becomes an issue. Not sure if you can reframe this issue with impls rather than functions. On the general question, I think that the abstraction boundary should be treated like the privacy boundary - it's ok for functions to leak information about the abstracted type if they have 'permission' to do so, in the same way that it is ok for a public function to return a private field. |
@nrc The issue @RalfJung is raising isn't tied to impls vs functions; it's straightforward to rewrite his example using traits/impls instead: mod foomod {
abstract type X: Clone = i32;
fn make_X() -> X { 42 }
}
trait Foo {
fn foo(self) -> Self;
}
impl<T> Foo for T {
default fn foo(self) -> T { self }
}
impl Foo for i32 {
fn foo(self) -> i32 { 42 }
}
fn bar() {
let x = foomod::make_X();
x.foo(); // which version of foo gets called?
} @RalfJung, to answer your question: in the type system, for clients of abstract types (aka impl Trait), the unpacked type already needs to be distinguished from the underlying concrete type to avoid other forms of leakage. As long as that distinct form doesn't unify with Likewise, when you're inside the scope of the abstraction, you know what the concrete type is, and hence the specialization should trigger. You claim that it's very easy to leak across the abstraction boundary with a specialized function, and you may be right, but your example doesn't quite show how. Can you make an example of the leakage you have in mind? In terms of the relation to type case, you should consider an unpacked abstract type to be essentially a fresh type about which we have certain assumptions (trait bounds) but is otherwise unanalyzable. That's exactly how it should appear to specialization. |
I actually don't think this is as clear as you make it out to be. In particular, which impl is chosen for specialization is a decision that can be made at type-checking time, but will frequently also be made at trans time, and I would have naively assumed that abstract types will be erased at trans time. It seems like you in contrast are describing something more like a newtype -- that is, the abstract I confess though that I, like @RalfJung, have not had time to keep up with this RFC nor its associated discussion thread, so I am commenting somewhat from my own "intutions" about how I expect |
This may be covered by what you said about the type being wrapped, but I thought of something loike this (again using function specialization syntax, since that's just shorter :D )
Naively, when the outside world calls |
(Or both of them could call the specialized variant, which is what I would expect.) |
Well, but then the abstraction is leaky, and we better don't have it. Existential types and type case fundamentally don't mix well. I don't think we should have both, the result is going to be confusing. Rust generally uses privacy as its abstraction mechanism, so we don't fundamentally need existential types. We can achieve similar effects with newtypes, so I'd be all in favor of adding sugar for supporting those better, making it easy to use just the existing privacy mechanisms to abstract away types -- the existing type system is expressive enough, just not convenient enough to use. |
It all depends on your perspective, I guess. I think this is precisely the kind of leak that specialization is meant to enable -- that is, it's sort of the "antiparametricity" feature.
I agree there is no single answer to this question that is always what a user would want or expect. |
The way I think about it, specialization doesn't break privacy, so it is not leaky for the only abstraction mechanism Rust uses so far. (Technically speaking, one could consider the types of closures existential types, but in any case closure types are not nameable and so do not interact with specialization - and it would also be technically possible to let closures rely on privacy only.) But calling a type Having "a bit of an abstraction", IMHO is worse than not having it. Either you have abstraction, and then you can rely on it even in your unsafe code, or you cannot rely on it, and then you better don't have it because people might think they can rely on it. (Note that I would argue the same in a language that doesn't have |
The problems you are describing seem to arise only if specialised functions behave differently than their generalisation. But wouldn't that be some kind of code smell? At least I would find it confusing and would expect using traits to define different behaviour for different types is better suited, isn't it?.. |
I think it is rather dangerous to rely on that. It would mean essentially relying on the client to uphold the module's invariants. Abstraction has been invented to avoid exactly this kind of anti-modularity, because experience show people will screw it up. |
Output type parameters are not supposed to be used for strong abstraction - the primary reason they are abstract is to avoid cross-function type-inference cycles and all the confusion that causes. impl-specialization is also at least supposed to only be used for performance reasons and have no observable effect. However judging by how @aturon tries to take that RFC I doubt this will stay that way for long in practice. |
I don't think "weak abstraction" is a thing. I also do not understand what any of this has to do with type inference. I really like this proposal, in particular how it makes "abstract return types" just syntactic sugar of a more generic principle. However, I think these types should be truly abstract. This would ensure, for example, that the implementation of iterator adapters like Maybe there are good reasons, so let me re-phrase this into a question: I searched for "newtype" in this discussion and the RFC, and found it mentioned only as something abstract types are not. So which goals of the RFC would not be achieved, if |
I agree with all of this, and you're right that I was probably assuming too much about how this would end up looking from trans's perspective. And I agree that the implicit (un)packs may be tricky -- but, on the other hand, the question of "what is the scope of abstraction for impl Trait" is one of the core questions we have to resolve for the design. Regarding @RalfJung's second example, I agree that the two calls must behave the same way. (Note: the example is a bit better if Taking a step back, remember that there are at least two separate motivations for
My earlier blog post goes into more detail, and discusses proposals that attack only one of the goals, as well as ones that attack both. At this point, I think there is broad consensus that the All that said, I'm not sure how problematic allowing specialization to "see through" this abstraction really is. Clearly, we would lose the guarantee that you can change the concrete type without breaking client code. But that guarantee is already available by using newtypes. And uses of specialization that meaningfully reveal the type (and would break if you change it) are probably rare. The benefit of making this abstraction transparent to specialization would be that you get a very straightfoward semantics for the interaction of the two features. On balance, I think I'd like to see how far we can get with a newtype-like semantics. Can we provide a good programmer model for the implicit packs/unpacks involved? |
Hah, I see @RalfJung is heading in roughly the same direction. I meant to give a concrete example: imagine you have code that returns |
I suspect the "newtype-like semantics" would not be nearly as simple as it sounds like. In general any approach which treats two types as equal in some parts of the type system and as different in others feels fraught with danger. The issue is that not all
I'm so glad that I'm not the only person with this perspective :) |
I agree, this is exactly what causes the specialization confusing in the proposal at hand. So I assume "newtype-like" refers to pretty much the proposal at hand, where "abstract type" is a newtype but the coercions are implicitly applied in the module that defines it? As opposed to, dunno, "fully newtype" semantics, where the coercions have to be stated explicitly (which is the proposal I made)? |
This is a pretty interesting conversation. But I want to push back a bit on
and then I have a client that does:
If we adopt a strong newtype semantics, then the client is going to lose Like everything else, it's a give and take. Abstraction lets you make more Put another way, one might be surprised that this function can potentially
But indeed, via specialization, it certainly can! So I don't think this On Fri, Jan 15, 2016 at 3:58 PM, Ralf Jung [email protected] wrote:
|
One final way to put this. I feel like the contract of an Update: To be clear, I think we can make it so that your client code will continue type-checking and compiling, even when you change the implementation (modulo OIBITS). The only way it can "observe" the type you return is based on the values generated by (and behavior of) specialized traits. |
@nikomatsakis or |
I see. This is a design goal I did not take into consideration. In this case, however, my first inclination is to suggest to not even try to make anything about that type "abstract". I think that will just cause confusion. A function Now, I think I can see a practical difference to the actual proposal at hand, which is that clients could (accidentally) rely on The questions of whether a type implements a trait is not changed by adding specialization, right? That's just all about which implementation is selected. So maybe another way to explain what kind of "hiding" is happening is that for an "abstract type", it is hiding whether the type implements some trait, but not which particular implementation is picked. The answer to "what is selected" in #1305 (comment) would then be "always the specialized version". Correct? This is probably the least-confusing possible behavior of the code I sketched, but I still don't like how this is some kind of semi-abstract type. It's more something of "upper-bounding" the trait implementations that people see for the type, rather than actually hiding anything. I think calling this "newtype-like" is rather confusing. I take it "upperbound" is not acceptable as the keyword here? ;-) . I'm not sure I have any good ideas for how to word/design this to be less confusing. (And maybe this is only confusing to people who know about parametricity and are thus tempted to assume that they actually control how the "abstract type" is inhabited?) |
See also http://aturon.github.io/blog/2015/09/28/impl-trait/
Yes. |
Yes, I noticed I was getting close to some parts of that post. That's why I added "my first inclination" and immediately went on ;-) |
cc: @icorderi |
Given that the author's alternative minimal impl Trait RFC has been merged, I'm going to close this one. |
Enable monomorphized return types of a function that are only abstractly defined by an trait interface by introducing a new set of "output" generic parameters that can be defined for a function, and extending trait bounds with the concept of "conditional" bounds.
Rendered