Open tgross35 opened 1 year ago
At first I was thinking that in my example, not having const fn
/ async fn
visible up front was a downside. After thinking about it for a bit though, I think it might actually be a plus: the functions are non-const by default (as most functions that you could pass to it are also non-const), but there are special conditions that make it const.
Relevant Zulip thread: https://rust-lang.zulipchat.com/#narrow/stream/328082-t-lang.2Fkeyword-generics/topic/~const.20desugaring
It was brought up there that there needs to be a way to specify bounds on a specific trait, rather than on a type. Possible solution:
fn foo<Closure, ItemTy>(closure: Closure) -> Option<ItemTy>
where
Closure: FnMut(&ItemTy) -> bool, // const bound is here
Closure: SomeOtherTrait, // but not here
fn: const if <Closure as FnMut>: const,
{ /* ... */ }
This extra bound specification would only be needed if type Closure
has >1 trait bound. The style would be quite consistent with the existing syntax of needing to narrow function calls like <SomeType as SomeTrait>:foo
when there are multiple traits provide a function named foo
.
I just wanted to hop in and note that this idea is kind of like treating effects as a special kind of marker trait. The proposed syntax above could be thought of as syntactic sugar for something that could be written like this:
impl<F, T> const for fn foo
where
F: FnMut(&T) -> bool,
F: const
It was brought up there that there needs to be a way to specify bounds on a specific trait, rather than on a type. Possible solution:
fn foo<Closure, ItemTy>(closure: Closure) -> Option<ItemTy> where Closure: FnMut(&ItemTy) -> bool, // const bound is here Closure: SomeOtherTrait, // but not here fn: const if <Closure as FnMut>: const, { /* ... */ }
Continuing my line of thought above, this need is then like saying that const
is a "trait for traits". The proposed syntax could then be the way of creating higher order trait bounds.
Continuing my line of thought above, this need is then like saying that const is a "trait for traits"
That's exactly the way I see it. They aren't the type: Trait
bounds we currently have, but they similarly bind a function or trait to a set of requirements. A const
function is required to not call non-const functions, an async
function must return a future, !panicking
functions can't call functions that may panic, and if user-defined effects are ever possible then they'd likely be similar.
I like the idea of !panics
and !unwinds
constraints (where !panics
implies !unwinds
). I feel like it could make unsafe code a lot simpler if you don't need to worry about user provided types unwinding because you wouldn't need as many drop guards and such. If you really wanted to you could even make a !unwinds
function wrapper that aborts if the provided function panics (sort of like how noexcept
behaves in C++).
Coming here from the recent WG report.
Please please do not stick with the ?async
and friends syntax. In a future where library maintainers can make their functions optionally async, we will, and many functions (maybe even most functions) will become littered with these bounds.
This means that picking something that isn't jarring is critical.
I really don't want to have to read Rust code that looks like this (from the linked report):
trait ?const ?async Read {
?const ?async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
?const ?async fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { .. }
}
/// Read from a reader into a string.
?const ?async fn read_to_string(reader: &mut impl ?const ?async Read) -> io::Result<String> {
let mut string = String::new();
reader.read_to_string(&mut string).await?;
Ok(string)
}
Not only is it very noisy, it's also very ugly; Rust code syntax is kind of already a meme (although I think it's beautiful in its own way), and a change like this would lean into that really hard. Code is read far more often than written, and making code easily legible is key to maintainable software. By mixing upper case (?
) with lower-case identifiers in front of the function, IMO the function becomes more difficult to parse and therefore less legible. Even with the older ~
syntax it still felt very noisy and off putting.
I do like the ?effect/.do
proposed solution to this, but it's important to recognize that not all functions will be valid for all effects.
For example, this is exactly the kind of thing I'd personally write:
/// We want to explicitly state that this can't panic, so we can't use `?effect` to do it generically.
///
/// Alternately, assume some keyword exists which we don't want to support,
/// or we have more keywords we don't want to support than ones that we do,
/// or we want to support all of today's keywords but not all future keywords forever, etc.
/// The point is to illustrate what it looks like with a bunch of keywords.
trait ?const ?async !panic !unwind Read {
?const ?async !panic !unwind fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
?const ?async !panic !unwind fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { .. }
}
/// Read from a reader into a string.
?const ?async !panic !unwind fn read_to_string(reader: &mut impl ?const ?async !panic !unwind Read) -> io::Result<String> {
let mut string = String::new();
reader.read_to_string(&mut string).await?;
Ok(string)
}
This reminds me of fused-effects in a really negative way.
Now, having complained, in regards to this specific issue, I think this (or something like it) is a really good idea.
By putting these constraints in the where
block, it reduces the apparently noise by moving into a spot where real estate is less premium and mixing upper/lowercase identifiers (or other odd mixes) already exists. Or in other words, "where
is already really noisy, so making it more noisy at least limits the blast radius".
If this issue were implemented, the function above becomes something like:
fn read_to_string(reader: &mut R) -> std::io::Result<String>
where
R: Read,
fn: async if R: async,
fn: const if R: const,
fn: !panic + !unwind,
{
let mut string = String::new();
reader.read_to_string(&mut string).await?;
Ok(string)
}
Which is much more tractable.
Overall, I love the idea of keyword generics, but I implore the WG to reconsider this syntax and deeply consider what'd be the best syntax for a version of Rust where these bounds were on the vast majority of functions, because I think that's where we're headed.
If this issue were implemented, the function above becomes something like:
fn read_to_string(reader: &mut R) -> std::io::Result<String> where R: Read, fn: async if R: async, fn: const if R: const, fn: !panic + !unwind, { let mut string = String::new(); reader.read_to_string(&mut string).await?; Ok(string) }
The !panic + !unwind
constraints probably couldn't be satisfied for this function specifically (What if the reader panics? Or what if it produces > isize::MAX
bytes and the string allocation panics?). But yes I agree, the where bounds look much more pleasant and readable to me too.
I agree regarding the current ~
/?
syntax being quite annoying to parse and read through. The above example
?const ?async !panic !unwind fn read_to_string(reader: &mut impl ?const ?async !panic !unwind Read) -> io::Result<String>
does not even really tell me what the actual function is about until half-way through the function signature, and then there is another long string of terms to figure out exactly what the function parameter is.
In contrast, with the below
fn read_to_string(reader: &mut R) -> std::io::Result<String>
where
R: Read,
fn: async if R: async,
fn: const if R: const,
fn: !panic + !unwind,
{
let mut string = String::new();
reader.read_to_string(&mut string).await?;
Ok(string)
}
I can tell exactly what is happening at a high overview, that read_to_string
takes a reader
with some properties that I can then look at in a further in the where
clause.
If I were in a large codebase with many such functions in the ~
/?
syntax, it would be difficult for me to parse through and figure out which function I'm looking for.
If fn
looks too much like a type or otherwise seems like a "magical" variable, another user on the /r/rust thread mentioned having effects even be after the where
clause via the effect
keyword:
fn foo<F, T>(closure: F) -> Option<T>
where
F: FnMut(&T) -> bool,
effect
const if F: const,
?async,
{ /* ... */ }
And here we can use the ?
prefix to signify "maybe" and also have the !
prefix to signify not.
Personally I like this last syntax the best since it is much more readable while still allowing the aforementioned prefixes and also not making fn
seem somewhat magical. If we have more types of effects in the future, maybe even a full-blown effects system, it is easy enough to add them to the effect
list.
What about allowing a "generic-like" syntax? For eg:
trait<R: ?const ?async !panic !unwind> Read<R> {
fn<R> read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
fn<R> read_to_string(&mut self, buf: &mut String) -> io::Result<usize> { .. }
}
fn<R: ?const ?async !panic !unwind> read_to_string(reader: &mut impl Read<R>) -> io::Result<String> {
let mut string = String::new();
reader.read_to_string(&mut string).await?;
Ok(string)
}
fn<R> foo<F, T>(closure: F) -> Option<T>
where
F: FnMut<R>(&T) -> bool,
R: ?const ?async !panic !unwind
{}
struct<R: ?const ?async !panic !unwind> File<R> {
async waker: Waker,
!async meta: Metadata,
}
impl<R: ?const ?async !panic !unwind> Read<R> for File<R> {
fn<R> read(&mut self, buf: &mut [u8]) -> io::Result<usize> { .. }
}
Also, there is an inconsistency in the keyword position. For traits, impls, & structs the generics come after the keyword but for functions, they come before. I think the above syntax solves that problem as well. The main issue I see with this approach is that since it looks like a generic, someone might try to add normal trait bounds to the generic. Also, the compiler should fill in the generic without the user having to provide it. Eg:
fn main() {
let file = File::new();
let out = read_to_string(file).unwrap();
}
Would it be possible to leave out the fn:
part like this?
I don't think this would create any ambiguity for the parser, since type generics are followed by :
and keyword generics are followed by if
, and async
and const
are already keywords.
fn foo_finder<It, Cl, I>(iter: It, closure: It) -> Option<I>
where
It: Iterator<Item = I>,
Cl: FnMut(&I) -> bool,
async if It: async + Cl: async,
const if Cl: const,
!panics if It: !panics + F2: !panics
{ /* ... */ }
keyword generics are followed by
if
This might not necessarily be true; an unconditional effect could be expressed using the postfix notation. It might still be unambiguous though.
Yeah something that is const or async doesn't necessarily have to have a conditional attached to it, as in the examples in my comment above, they can still be maybe const
or not async
, for example, with no conditions on when they would be.
This might not necessarily be true; an unconditional effect could be expressed using the postfix notation. It might still be unambiguous though.
For empty type generic constraints rustc requires you to add a :
, so it is only consistent to require empty keyword generic constraints to also have an if
:
fn f<T>()
where T:,
{ // rustc requires you to add a `:` after `where T`
}
fn g()
where async if,
{ // should also require an `if` after `where async`
}
if
would be consistent but it feels weird semantically. Async if...what? Nothing? Maybe we should just have a colon after async and const as well too.
I think this also has the potential to power or clarify AND versus OR relationship:
~async fn async_find<I>(
iter: impl ~async Iterator<Item = I>,
closure: impl ~async FnMut(&I) -> bool,
) -> Option<I>
Must mean one of two things:
async_find
is async IFF iter
AND closure
are asyncasync_find
is async IFF iter
OR closure
are asyncI think the +
in this proposal very obviously means AND. I think ~async
probably has to mean AND as well or would become rather confusing. But I think OR should be possible and under this proposal it could rather naturally be something like |
, overall looking like fn: async if A: async | B: async
.
Here's some usage examples:
// Should always be possible with either interpretation of how `~async` or the like works.
async_find(async_iter, |i| async { i > 3 }).await;
async_find(sync_iter, |i| i > 3);
// Only possible with OR relationship
async_find(async_iter, |i| i > 3).await;
async_find(sync_iter, |i| i > 3).await;
I feel like this would be a reasonably common desire.
I think the main downside is that someone might expect to be able to use |
outside of this proposal's where clause or to make really hairy where clauses. Is there even a use case for these honestly unideal where clauses fn: async if A: async + B: async | C: asnyc
, fn: async if (A: async | B: async) + C: async
or even fn: async if (A: async | B: async) + (C: async | D: async)
? It could be simply be disallowed but it is a genuine downside with my additional proposal that people might end up expecting to be able to do that. If there's a use case though it might be quite nice.
By comparison I think ~async
probably has to mean AND for reasons I'll get to later. If OR relationships were to be added later, something like @async
(or some other random sigil) would have to be added to mean OR. This might allow avoiding the potentially very hairy where clauses (at the least implementing error messages telling you that you can't write them) but I certainly would forget what @
versus ~
means and |
and +
are much clearer but doesn't work well out of a where clause, +async
or |async
just doesn't work as well.
I think ~async
has to mean AND for consistency with ~const
which probably has to mean AND. For the const and likely !panic, along with maybe other effects the OR relationship doesn't really seem to make sense:
~const fn const_find<I>(
iter: impl ~const Iterator<Item = I>,
closure: impl ~const FnMut(&I) -> bool,
) -> Option<I>
It's probably reasonable to assume almost most functions will use every parameters and since you obviously can't iterate a non-const iterator or call a non-const closure inside a const
function so const_find
can't be, well, const useless unless both iter
AND closure
are const. In comparison to the ergonomics of OR in the context of async, in const AND just makes a lot more sense.
All in all I really like this and I basically feel like |
to represent some effect conditions is a pretty natural extension to this syntax compared to trying to extend ~const
syntax and I anticipate people will end up wanting this capability.
I suggested an alternative syntax in rust-lang/rust#107003, but it seems like this discussion is here. How does using multiple where
bounds and allowing where
bounds to have an effect keyword look to folks instead?
fn read_to_string(reader: &mut R) -> std::io::Result<String>
where
R: Read,
where async
R: async Read,
where const
R: const Read,
{
let mut string = String::new();
reader.read_to_string(&mut string).await?;
Ok(string)
}
It doesn't front load the signature, makes effect bounds explicit (e.g. a different bounds could be used for different effects later one such as AsyncRead
), and uses familiar syntax.
It also has the added benefits of visually separating effect bounds too, and doesn't involve using a fn
keyword to reference the fn
itself.
I'm in the process of creating an overview of some of the alternative syntax designs, basing it on the following snippet:
/// A trimmed-down version of the `std::Iterator` trait.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>);
}
/// An adaptation of `Iterator::find` to a free-function
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
I: Iterator<Item = T> + Sized,
P: FnMut(&T) -> bool;
@tgross35 I'd be interested in the following two translations of this snippet to your design:
async
.async
.effect/.do
semantics).If you believe more variants would be helpful to include as well, please feel free to. Thank you!
edit: We now have a template which can be filled out. That should make it easier to keep track of the various designs.
If anyone else in the thread wants to contribute their designs based on the snippet above, please feel free to. This will help make it easier to compare the syntactic choices made in each design. Thank you!
To share an example of a design overview, here is what the syntax we showed off in the progress report looks like using the snippet as a base:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>);
}
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
I: Iterator<Item = T> + Sized,
P: FnMut(&T) -> bool;
pub trait async Iterator {
type Item;
async fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>);
}
pub async fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
I: async Iterator<Item = T> + Sized,
P: async FnMut(&T) -> bool;
pub trait ?async Iterator {
type Item;
?async fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>);
}
pub ?async fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
I: ?async Iterator<Item = T> + Sized,
P: ?async FnMut(&T) -> bool;
A slight modification from the report, using effect
instead of ?effect
.
pub trait effect Iterator {
type Item;
effect fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>);
}
pub effect fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
I: effect Iterator<Item = T> + Sized,
P: effect FnMut(&T) -> bool;
An idea for effects as similar to const-generic booleans.
Translating examples:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>);
}
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
I: Iterator<Item = T> + Sized,
P: FnMut(&T) -> bool;
pub async trait Iterator {
type Item;
// function assumed async since trait is
fn next(&mut self) -> Option<Self::Item>;
!async fn size_hint(&self) -> (usize, Option<usize>);
}
// or
pub trait Iterator<effect async> {
type Item;
fn next(&mut self) -> Option<Self::Item>;
fn size_hint<effect !async>(&self) -> (usize, Option<usize>);
}
// or
pub trait Iterator where effect async {
type Item;
fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>) where effect !async;
}
pub async fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
I: Iterator<Item = T> + Sized,
P: async FnMut(&T) -> bool;
// or
pub fn find<I, T, P, effect async>(iter: &mut I, predicate: P) -> Option<T>
where
I: Iterator<Item = T> + Sized,
P: FnMut<effect async>(&T) -> bool;
pub trait Iterator<effect A: async> {
type Item;
// `<effect async = A>` elided
fn next(&mut self) -> Option<Self::Item>;
!async fn size_hint(&self) -> (usize, Option<usize>);
// or
fn size_hint<effect !async>(&self) -> (usize, Option<usize>);
// or
fn size_hint(&self) -> (usize, Option<usize>) where effect !async;
// as opposed to `where A = !async` which would make this function
// only exist if we're in a context where `Iterator<A = true>`
}
pub fn find<I, T, P, effect A: async>(iter: &mut I, predicate: P) -> Option<T>
where
I: Iterator<Item = T, effect async = A> + Sized,
P: FnMut<effect async = A>(&T) -> bool;
This would likely require adding quantification over effects,
similar to for<'a>
. Syntax not fully thought out, but approximately:
pub trait Iterator where for<effect E> ?E {
type Item;
// something like `where for<effect E> ?E` elided
fn next(&mut self) -> Option<Self::Item>;
!async fn size_hint(&self) -> (usize, Option<usize>);
// or
fn size_hint<effect !async>(&self) -> (usize, Option<usize>);
// or
fn size_hint(&self) -> (usize, Option<usize>) where effect !async;
}
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
for<effect E> A: E,
I: Iterator<Item = T, for<effect E> A = E> + Sized,
P: FnMut<for<effect E> A = E>(&T) -> bool;
!async fn
could also be sync fn
or just dropped entirely in favor of fn foo<effect !async>
For more details:
Some convenient things about this syntax:
@sayaks I just realized I forgot to add something important to the snippet: a size_hint
method which is guaranteed to never be async. This should cover being able to mix async and non-async methods in a single trait, which is an important requirement for any plausible design.
Can I ask you to perhaps update your design sample to include size_hint
? Apologies for the inconvenience.
@yoshuawuyts oki done
here is i think a better syntax for the "generic over all keywords" case
pub trait Iterator<effect A: for<effect>> {
type Item;
fn next(&mut self) -> Option<Self::Item>;
!async fn size_hint(&self) -> (usize, Option<usize>);
}
pub fn find<I, T, P, effect A: for<effect>>(iter: &mut I, predicate: P) -> Option<T>
where
I: Iterator<Item = T, for<effect> = A> + Sized,
P: FnMut<for<effect> = A>(&T) -> bool;
however this would mean size_hint
is generic over all effects except async, so it might be good to make such universal bounds not automatically applied to all items in a trait, in that case we'd have
pub trait Iterator<effect A: for<effect>> {
type Item;
fn next(&mut self) -> Option<Self::Item> where for<effect> = A;
fn size_hint(&self) -> (usize, Option<usize>);
}
alternatively we could have an opt-out syntax for the implicit bound
pub trait Iterator<effect A: for<effect>> {
type Item;
fn next(&mut self) -> Option<Self::Item>;
fn size_hint(&self) -> (usize, Option<usize>) where for<effect> = default;
}
@sayaks Instead of tracking design proposals in a GitHub thread, I figured it might actually be better if we start checking them in. Can I ask you to create a branch based off this template and file a PR containing your design? That should make it easier to look up the design later on. If it's easier if I do it, just let me know. Thank you!
An idea for effects as similar to const-generic booleans.
@sayaks For the "generic over all keywords" case, I think rather than inventing new syntax you may be able to treat it as similar to a const-generic Effects, where Effects is a struct with a boolean field for each effect.
?fn read_to_string(reader: &mut R) -> std::io::Result<String>
where
?: ?async ?const !panic !unwind,
R: Read,
{
let mut string = String::new();
reader.read_to_string(&mut string).await?;
Ok(string)
}
In my opinion, the function is impacted directly with optional behaviours. A shortcut (aka interrogation mark or something else) before or after 'fn' should point to a where clause to attract the developer and tell what is 'hidden'...
Since the latest RFC draft that was checked in uses an attribute syntax, I assume that's the plan going forward, and this should be closed as complete?
The attribute notation in the draft RFC is intended mainly as a placeholder syntax. It mentions picking a syntax as an unresolved question.
Ah, I guess that's what I get for just skimming the draft. Makes sense.
Opinion
I stumbled upon this repo as a complete outsider, and one of the things that stood out to me was the syntax. Taking an example from the book:
Or (I think) its equivilant
where
I find it a bit difficult to read:
~
is a destructor in c++, bitwise NOT in C and some others, and used to be a heap operator in former Rust. Its usage for "if and only if" relationships is foreign to me and not super intuitive (maybe some other languages use something similar, I'm not aware)~
on its own is kind of a weird character, it's the same width as a letter but sort of visually floats. So it means that if some functions on a page are~async
and some aren't, thefn
keywords misalign just enough to be mildly annoying:The last thing is a very subjective visual nitpicks, but I think in general this syntax has a potential to get a bit messy (are combinations like
~const ~async
eventually expected?)Suggestion
I think that there's likely a way to leverage trait bound syntax to express these things, in a way that is already familiar. As a simple example:
And an example with multiple bounds with more complex relationships:
Advantages as I see them:
fn: async if It: async + Cl: async,
says almost perfectly "this function is async if both iteratorIt
and closureCl
are async".!panics
/!panicking
here), and it still looks visually consistentfn: async if Cl: const
orfn: async if I: Sync
. I can't really visualize a use case for it, but at least it's possible.fn: const if Self::get_or_init: const
A downside is that it wouldn't be as simple to express this using the inline syntax with
impl
as shown above.Anyway, not sure if something like this has been discussed or if there's a specific reason it wouldn't work, but just wanted to share my 2¢.