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

RFC: Attributes in function return type position #3201

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

not-my-profile
Copy link

@not-my-profile not-my-profile commented Nov 25, 2021

#2565 introduced attributes for function parameters. This RFC proposes the logical next step: allowing attributes for function return types. This RFC is less radical than #2602 which proposes that attributes should be allowed to be attached nearly everywhere (lifetimes, types, bounds, and constraints), which has been argued to go a bit too far resulting in too much cognitive load. This RFC hopes to increase the expressiveness of DSLs without posing too much cognitive load.

Rendered

@Diggsey
Copy link
Contributor

Diggsey commented Nov 25, 2021

Is there any precedent in other languages for allowing attributes on return types? It looks out of place to me, but it might just be because I'm not used to seeing attributes in that position.

Presumably if you wish to place a return type attribute on a unit-returning function, you must explicitly state the return type? ie.

I see this is covered now.

fn example() -> #[attr] () {
}

Also, #2565 allows attributes on closure arguments as well as functions. This RFC should also state whether closure return types can be annotated.

@ehuss ehuss added the T-lang Relevant to the language team, which will review and decide on the RFC. label Nov 25, 2021
@scottmcm
Copy link
Member

So attributes on parameters were clearly helpful since there are multiple -- a function-level attribute would have had to match up the name or something, which would be awkward.

But the distinction between a function and its return type are much less clear-cut to me. For example, I'm not sure it's clearer to say that the the Vec<Page> is returned as json than to just say that the example HTTP endpoint returns json. Similarly, I'm not sure it'd be better to have

fn to_lowercase(&self) -> #[must_use] String;

than the current

#[must_use]
fn to_lowercase(&self) -> String;

even if you could argue that it's the return value that needs to be used. (And I acknowledge that the RFC doesn't propose changing must_use, but it seemed a useful study.)

So I think overall my first instinct here is "weak no due to insufficient motivation". But that's weakly held, so could change.

@clarfonthey
Copy link
Contributor

Small nit: could you define DSL in the RFC text please? Since I'm not quite sure what you mean there.

@not-my-profile
Copy link
Author

not-my-profile commented Nov 25, 2021

@Diggsey Yes as I wrote in the RFC unit-returns would need to be made explicit. I am not aware of a precedent for return type attributes in other languages, which is also why I put that very question under "unresolved questions". Thanks, good catch with the closures! I think for consistency attributes should be supported for their return types as well (I updated the RFC accordingly).

@clarfonthey Thanks, I clarified that DSL stands for domain-specific language.

@scottmcm Thanks, you raise a good points. I agree that the motivation for return type attributes is weaker than for parameter attributes but I think for certain DSLs they would still be desirable enough to justify their addition to the language. I agree that the json return type wasn't the best example ... I updated the motivation with a better example:

#[wasm_bindgen]
impl RustLayoutEngine {
    pub fn layout(
        &self,
        #[type = "MapNode[]"] nodes: Vec<JsValue>,
        #[type = "MapEdge[]"] edges: Vec<JsValue>
    ) -> #[type = "MapNode[]"] Vec<JsValue> {
        ..
    }
}

is in my opinion clearly preferable to

#[wasm_bindgen]
impl RustLayoutEngine {
    #[return_type = "MapNode[]"]
    pub fn layout(
        &self,
        #[type = "MapNode[]"] nodes: Vec<JsValue>,
        #[type = "MapEdge[]"] edges: Vec<JsValue>
    ) -> Vec<JsValue> {
        ..
    }
}

So I think return type attributes would primarily be useful for specifying another return type that the function return type is somehow mapped to via the generated code. In that case having both the actual and the "mapped" return types next to each other makes the code more readable and facilitates maintenance (if one is updated the other type likely should be updated as well, which is easier to do when they're next to each other).

@Boboseb
Copy link

Boboseb commented Nov 25, 2021

Is there any precedent in other languages for allowing attributes on return types? It looks out of place to me, but it might just be because I'm not used to seeing attributes in that position.

C# allows attribute on return values https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/attributes/#attribute-targets but I don't remember any example

@ogoffart
Copy link

If you're looking for other example of confusing language that can put attributes everywhere, we can take C++

[[function]]
auto [[type1]] 
my_function([[arg]] int [[type2]] * [[type3]] my_arg) [[function_type]] 
        ->  int [[type4]] * [[type5]] {
    return my_arg;
}

Notice that it is hard to see what exactly is annotated. Unlike in rust, the attribute sometimes comes before, sometimes after what it describes. I'm not even quite sure about what i annotated, but i think type1 and type5 annotates the return type (int*), and function_type annotates the function type (in this case int* (*) (int *)), while [[function]] annotates the whole function.

Copy link
Member

@nrc nrc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this idea - it's a small quality of life improvement and there doesn't seem to be much downside (it doesn't really increase the complexity of the language).

One thought is that if in the future we support throws/yeets or yield as a way to specify types which are sort-of returned, we'd presumably want to allow attributes there. I don't see that being difficult or interesting, but perhaps worth mentioning?

# Reference-level explanation
[reference-level-explanation]: #reference-level-explanation

TODO
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to check the parser's grammar to ensure that adding an attribute before the return type does not break anything (I don't expect that it will).

@Ixrec
Copy link
Contributor

Ixrec commented Dec 17, 2021

#3201 (comment) pointed out where C++ allows attributes in its syntax, but for this RFC, concrete examples of attributes that do something in return position are probably more relevant as prior art, so:

Is there any precedent in other languages for allowing attributes on return types? It looks out of place to me, but it might just be because I'm not used to seeing attributes in that position.

C++ has the [[nodiscard]] attribute, which for the purposes of this discussion is basically #[must_use]. There's also [[noreturn]], but Rust already chose a never type for that use case. I believe that's it for standardized attributes that apply to a function's return type, as opposed to the entire function.

@petar-dambovaliev
Copy link

I think this would be useful for data transformation.
For example, picture a web framework handler like so

#[handler]
async fn get_user() -> #[json] User {
    User{...}
}

@nikomatsakis
Copy link
Contributor

I'm in favor of the spirit of this proposal (I like attributes and extensibility!) but I am concerned about a sort of "ambiguity" -- is this attribute attached to the function's return or the type that it returns. It's a subtle difference and maybe it doesn't matter, but it seems a bit ambiguous to me.

I'm thinking: I could imagine us adding attributes to types in the future. As an example, consider #[non_null] being added to *mut to indicate that it is not null (probably not a good idea in Rust, but there is a lot of precedent for this in languages like Java, so let's run with it). Then you could have fn foo(x: #[non_null] *mut T) or fn foo() -> #[non_null] *mut T. But now it's kind of ambiguous whether that non_null is attached to the return type or the return value.

This may be a distinction without a difference, I'm not sure. #[must_use] is an interesting example. We have "must use" types, and arguably it makes sense to think of -> #[must_use] String as returning a value of type #[must_use] String.

But in that case, maybe we just want to allow attributes to be attached to types instead and be done with it?

Can anyone come up with examples where these two interpretations would be in conflict?

@nrc
Copy link
Member

nrc commented Feb 8, 2022

Can anyone come up with examples where these two interpretations would be in conflict?

The obvious case is where the function returns something in the implementation which is not the declared type, e.g., the future vs the value in an async function (or similar with 'yeet' syntax. etc).

I wonder if there is an extension to that, like what if you have an annotation on the return type of a function, say T, and then refactor the function to return Result<T>. Should the annotation change? Is it possible to change it to do the right thing in all cases?

@clarfonthey
Copy link
Contributor

If there were to be a difference between annotating the "return" part and the type itself, I would say that specifically annotating the return should go before the arrow, and annotating the type should go after.

@nikomatsakis
Copy link
Contributor

@clarfonthey

If there were to be a difference between annotating the "return" part and the type itself, I would say that specifically annotating the return should go before the arrow, and annotating the type should go after.

This makes sense to me.

@ssokolow
Copy link

ssokolow commented Feb 9, 2022

My concern would be how common the two use-cases are, since it feels strange and unnatural to me to annotate something I perceive to be in the same class of syntax as a curly brace or equals sign:

fn unannotated_function() -> UnannotatedBar #[annotation_on_curly_brace] {
    // Un-annotated body
#[annotation_on_curly_brace]
}
let unannotated_name: UnannotatedType #[annotation_on_equals] = UnannotatedType::new();

...possibly as unnatural as something like this:

let foo =(argument_to_call_equals_as_a_function) Bar::new();

To use a natural language comparison, annotations are placed on words, while -> is a punctuation mark. It'd be like asking if " and , are nouns or verbs.

@scottmcm
Copy link
Member

scottmcm commented Feb 9, 2022

I'm a bit skeptical on the practicality of annotating types specifically, since flowing them through generics properly seems very awkward. Trying to get Vec<(A, #[non_exhaustive] B)> to work scares me, vs Vec<(A, NewType<B>)> just generally does.

@kanashimia
Copy link

A precedent for this is WGSL, they heavily use return type attributes.
This would be useful for example in rust-gpu, they currently use MaybeUninit out params instead.

Here is an example in WGSL:

@vertex
fn main_vs(
    @builtin(vertex_index) vert_id: u32
) -> @builtin(position) vec4<f32> {
    let x = f32(i32(vert_id) - 1);
    let y = f32(i32(vert_id & 1u) * 2 - 1);
    return vec4<f32>(x, y, 0.0, 1.0);
}

@fragment
fn main_fs() -> @location(0) vec4<f32> {
    return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

Here is how it looks in rust-gpu currently:

#[spirv(vertex)]
pub fn main_vs(
    #[spirv(vertex_index)] vert_id: i32,
    #[spirv(position)] out_pos: &mut Vec4,
) {
    let x = (vert_id - 1) as f32;
    let y = ((vert_id & 1) * 2 - 1) as f32;
    *out_pos = vec4(x, y, 0.0, 1.0);
}

#[spirv(fragment)]
pub fn main_fs(output: &mut Vec4) {
    *output = vec4(1.0, 0.0, 0.0, 1.0);
}

Here is how it could look:

#[spirv(vertex)]
pub fn main_vs(
    #[spirv(vertex_index)] vert_id: i32,
) -> #[spirv(position)] Vec4 {
    let x = (vert_id - 1) as f32;
    let y = ((vert_id & 1) * 2 - 1) as f32;
    vec4(x, y, 0.0, 1.0)
}

#[spirv(fragment)]
pub fn main_fs() -> Vec4 {
    vec4(1.0, 0.0, 0.0, 1.0)
}

@kennytm
Copy link
Member

kennytm commented Apr 18, 2024

if we use the alternative syntax from #3201 (comment) which does not suffer from the "don't know whether it's annotating the type or the function return" issue, the example above will become

#[spirv(vertex)]
pub fn main_vs(
    #[spirv(vertex_index)] vert_id: i32,
) #[spirv(position)] -> Vec4 {
    let x = (vert_id - 1) as f32;
    let y = ((vert_id & 1) * 2 - 1) as f32;
    vec4(x, y, 0.0, 1.0)
}

@kennytm
Copy link
Member

kennytm commented Apr 18, 2024

What happened if we placed #[cfg] on the return type?

fn foo() #[cfg(unix)] -> u32 {
    todo!();
}

if we follow the rule of #![feature(stmt_expr_attributes)] (rust-lang/rust#15701) this should emit an E0658 "removing the return type is not supported in this position" error.

And what if this is a proc-macro?

fn foo() #[my_crate::my_attribute] -> u32 {
    todo!();
}

I suppose this would emit the error "expected non-macro attribute, found attribute macro" similar to the argument position.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T-lang Relevant to the language team, which will review and decide on the RFC.
Projects
None yet
Development

Successfully merging this pull request may close these issues.