Skip to content
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

Possible alternative to tilde/? using bound-like syntax #10

Open
tgross35 opened this issue Feb 1, 2023 · 28 comments
Open

Possible alternative to tilde/? using bound-like syntax #10

tgross35 opened this issue Feb 1, 2023 · 28 comments

Comments

@tgross35
Copy link

tgross35 commented Feb 1, 2023

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:

~async fn async_find<I>(
    iter: impl ~async Iterator<Item = I>,
    closure: impl ~async FnMut(&I) -> bool,
) -> Option<I>

Or (I think) its equivilant where

~async fn try async_find<It, Cl, I>(iter: It, closure: It) -> Option<I>
where:
    It: ~async Iterator<Item = I>,
    Cl: ~async FnMut(&I) -> bool

I find it a bit difficult to read:

  • To somebody new to the syntax, there's nothing that really indicates the "asyncness" of the function is related to the asyncness of the iterator and closure.
  • ~ 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, the fn keywords misalign just enough to be mildly annoying:
async fn foo(F: impl async Iterator) ...

~async fn bar(F: impl ~async Iterator) ...

async fn some_other_foo(F: impl async Iterator) ...

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:

fn foo<F: FnOnce(i32)>(f: F)
where
    fn: const if F: const,
{ /* ... */ }

And an example with multiple bounds with more complex relationships:

fn foo_finder<It, Cl, I>(iter: It, closure: It) -> Option<I>
where
    It: Iterator<Item = I>,
    Cl: FnMut(&I) -> bool,
    fn: async if It: async + Cl: async,
    fn: const if Cl: const,
    fn: !panics if It: !panics + F2: !panics
{ /* ... */ }

Advantages as I see them:

  • I find this to be much more expressive: something like fn: async if It: async + Cl: async, says almost perfectly "this function is async if both iterator It and closure Cl are async".
  • There's no problem adding >1 keyword bound (async and const, as well as the fake !panics / !panicking here), and it still looks visually consistent
  • More nuanced trait bounds are possible, like fn: async if Cl: const or fn: async if I: Sync. I can't really visualize a use case for it, but at least it's possible.
  • This could likely be leveraged in some way with traits / associated functions. fn: const if Self::get_or_init: const
  • It's more familiar and doesn't look like something from a different programming language, and it's no problem to add >1 keyword bound. We already express both trait bounds and lifetimes in this way, why not just extend it to express keyword things?

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¢.

@tgross35
Copy link
Author

tgross35 commented Feb 2, 2023

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.

@tgross35
Copy link
Author

tgross35 commented Feb 17, 2023

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.

@tgross35 tgross35 changed the title Possible alternative to tilde using bound-like syntax Possible alternative to tilde/? using bound-like syntax Feb 23, 2023
@obsgolem
Copy link

obsgolem commented Feb 23, 2023

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.

@tgross35
Copy link
Author

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.

@Sp00ph
Copy link
Member

Sp00ph commented Feb 23, 2023

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++).

@jssblck
Copy link

jssblck commented Feb 23, 2023

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.

@Sp00ph
Copy link
Member

Sp00ph commented Feb 24, 2023

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.

@satvikpendem
Copy link

satvikpendem commented Feb 24, 2023

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.

@utkarshgupta137
Copy link

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();
}

@leslie255
Copy link

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
{ /* ... */ }

@obsgolem
Copy link

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.

@satvikpendem
Copy link

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.

@leslie255
Copy link

leslie255 commented Feb 24, 2023

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`
}

@satvikpendem
Copy link

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.

@DavidArchibald
Copy link

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 async
  • async_find is async IFF iter OR closure are async

I 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.

@edward-shen
Copy link

edward-shen commented Feb 24, 2023

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.

@yoshuawuyts
Copy link
Member

yoshuawuyts commented Feb 26, 2023

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:

  1. A variant where all items are always async.
  2. A variant where all items are generic over async.
  3. A variant where all items are generic over all available modifier keywords (e.g. 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!

@yoshuawuyts
Copy link
Member

yoshuawuyts commented Feb 26, 2023

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:

base (reference)

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;

always async

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;

maybe async

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;

generic over all modifier keywords

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;

@lilizoey
Copy link
Contributor

lilizoey commented Feb 26, 2023

An idea for effects as similar to const-generic booleans.

Translating examples:

base (reference)

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;

always async

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;

maybe async

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;

generic over all modifier keywords

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:

We introduce a new kind of generic, effect generics.
An effect may either be true, or false.
We can create a new effect by doing:

  • A + B, which is true whenever both A and B are
  • A | B, which is true whenever either A or B are
  • !A, which is true whenever A is false

When writing generics, we may add effect E = bool, where bool is either true or false, at the end of the list, after const generics.
As an example let's take:

fn foo<T, O, const N: usize, effect async = true>(...) {...}

Syntactic sugar:

  • effect E will be equivalent to effect E = true, and
  • effect !E will be equivalent to effect E = false
    so foo from above may be more cleanly written as
fn foo<T, O, const N: usize, effect async>(...) {...}

async fn can also be defined as syntactic sugar for that, meaning the below function is equivalent

async fn foo<T, O, const N: usize>(...) {...}

We may also move the bound to the where clause

fn foo<T, O, const N: usize>(...) where effect async {...}

To be generic over an effect E you'd add an effect A: E,
so to make the above function generic over async-ness you'd write

fn foo<T, O, const N: usize, effect A: async, effect async = A>(...) {...}

If there's only one generic of a specific effect, we can elide the effect E = A,
and A will be assumed to be the one effect of that value
so equivalently we can write

fn foo<T, O, const N: usize, effect A: async>(...) {...}

Additionally we can introduce effect ?E as syntactic sugar for effect A: E, effect E = A
so now we can write the above as
fn foo<T, O, const N: usize, effect ?async>(...) {...}

If there are multiple generics over one effect you'd need to clarify whether the function has that effect
For instance to say a function foo is async if A1 or A2 is async, you'd write:

fn foo<effect A1: async, effect A2: async, effect async = A1 | A2>(...) {...}

Every effect will have an assumed default of either true or false
for instance:
const has default false
async has default false
panic has default true
If there is no effect-bound on an item, it is assumed to be its default value.

when marking something as something other than the default, this may change the type
for instance, marking a function fn foo() -> T as fn foo<effect async>() -> T
would make it so that T is a Future<T> instead.

In a trait, every item is assumed to have the same effect-bounds as the trait itself,
unless otherwise specified

Some convenient things about this syntax:

To make a function have specific behavior for when it is async/sync we could do:
fn foo<effect A: async>() {
    if A {
        // do stuff when foo is async
    } else {
        // do stuff when foo is not async
    }
}

impl blocks can also look and be used in a very familiar way:

impl<effect A: async> SomeTrait<effect async = A> MyGenericType { ... }
impl SomeTrait<effect async> MyAsyncType { ... }
impl SomeTrait<effect !async> MySyncType { ... }

@yoshuawuyts
Copy link
Member

yoshuawuyts commented Feb 26, 2023

@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.

@lilizoey
Copy link
Contributor

@yoshuawuyts oki done

@lilizoey
Copy link
Contributor

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;
}
Description

Every type can have a bound placed for all effects over that type, this is done by using for<effect> = A, where A is some bound over all effects.
By default, we'd have every type implicitly have a for<effect> = default bound, which just means every effect has its default value. (for instance, false for const or true for panic.)
<effect A: for<effect>> creates a universal bound over effects named A where every effect is generic.

a single effect can also be bound by a universal bound, by doing effect async = A. this would mainly be useful in cases where there are multiple universal bounds and you want to be specific about certain effects.

in theory this could allow the syntax <for<effect> = true/false>, but that seems like it'd not be very useful, since we'd likely have some effect be true by default and others false by default.

we could also have <for<effect> = !default> but that again doesn't seem very useful. not only is it difficult for the user to know they are in fact using the non-default effect for every effect. but also it'd make it very easy to add breaking changes. Because if a new effect is added, code that used to compile would stop compiling if it's not compatible with the new effect.

Here is an example of multiple universal bounds, note that you now need to specify how to interpret the effect-status of that type since the compiler can't know what you'd want.

fn foo<O, F1, F2, effect A: for<effect>, effect B: for<effect>>(closure1: F1, closure2: F2) -> O
where
    // the entire function has an effect, if either A or B has it
    for<effect> = A | B,
    // however the function is const only when both A and B are const
    effect const = A + B,
    F1: FnMut<for<effect> = A>() -> O,
    F2: FnMut<for<effect> = B>() -> O
{ ... }

For instance, this would mean that foo is async if either closure1 or closure2 is async.

This however may not end up working out in practice, i could imagine it not really being possible to write any useful code with such complicated bounds over universal quantifiers. in that case it might be better for each effect to have a default way of combining (const would likely be A + B by default, whereas async is A | B by default). It might feel a bit more magical that way, but might be the only reasonable thing to do. In that acse the above function would look like this:

fn foo<O, F1, F2, effect A: for<effect>, effect B: for<effect>>(closure1: F1, closure2: F2) -> O
where
    F1: FnMut<for<effect> = A>() -> O,
    F2: FnMut<for<effect> = B>() -> O
{ ... }

It would be possible to specify bounds for specific effects though,

fn foo<effect A: for<effect>, effect async>() { ... }
// or
async fn foo<effect A: for<effect>>() { ... }

This is a function that is always async, but generic over every other effect.
Or with multiple universal bounds in the case where the universal bond is implicit:

fn foo<O, F1, F2, effect A: for<effect>, effect B: for<effect>>(closure1: F1, closure2: F2) -> O
where
    effect async = A + B,
    F1: FnMut<for<effect> = A>() -> O,
    F2: FnMut<for<effect> = B>() -> O
{ ... }

This function would be async if both closures are async, instead of either of them as it would be by default.

@yoshuawuyts
Copy link
Member

yoshuawuyts commented Feb 26, 2023

@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!

@PeterHatch
Copy link

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.

@Alphapage
Copy link

?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'...

@clarfonthey
Copy link

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?

@yoshuawuyts
Copy link
Member

The attribute notation in the draft RFC is intended mainly as a placeholder syntax. It mentions picking a syntax as an unresolved question.

@clarfonthey
Copy link

Ah, I guess that's what I get for just skimming the draft. Makes sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests