A discussion about addressing inconsistencies with cfg-attributes?

I've been working on some projects requiring conditional compilation and have run into a number of inconsistencies or gaps in coverage in where you can and can't use #[cfg] in Rust code. This is a pretty important tool to be able to use in a lot of places, and so I'd like to help bridge some of these gaps where possible. Awhile back I proposed RFC 3399, which was accepted, and am currently proposing RFC 3532.

RFC 3532 addresses this particular inconsistency with tuples:

Legal:

fn main() {
    let x = (2, "string", #[cfg(condition)] false); // OK!
}

Legal:

pub struct MyTupleStruct(SomeA, SomeB, #[cfg(condition)] SomeC); // OK!

Illegal:

type SomeTuple = (SomeA, SomeB, #[cfg(condition)] SomeC); // Not OK!
//                              ^^^^^^^^^^^^^^^^^ - Can't use cfg here

I've actually hit this issue in a use case in my library, gecs, which I have been working on trying to convert to use fewer proc macros and instead use type inference from tuples, but I can't do it if I can't ergonomically support conditional compilation there.

I've come across another similar issue recently that has another familiar inconsistency, but haven't done any sort of write-up for it yet:

Legal:

fn something<T0, #[cfg(condition)] T1>(a: T0, #[cfg(condition)] b: T1) {} // OK!

Legal:

fn main() {
    something(1, #[cfg(condition)] 2); // OK!
}

Illegal:

fn main() {
    something::<u32, #[cfg(condition)] i32>(1, #[cfg(condition)] 2); // Not OK!
    //               ^^^^^^^^^^^^^^^^^ - Can't use cfg here
}

These inconsistencies defy expectation in fairly unintuitive ways and can lead to frustrating workarounds and boilerplate. They can require wholesale duplication of a lot of code for cfg-branching, which gets even worse when you have multiple cfg-attribute conditions interacting on the same line or block as the result is combinatorial. This is an area where locality of annotation is very important, and the farther removed your cfg-attribute code annotation is from the code you actually intend to gate behind a flag, the more likely you are to lose details and create bugs.

Is there some sort of global decision-making here, or are these just oversights in the grammar? I'm inclined to continue writing RFCs to fix these sorts of inconsistencies, but it's a considerable effort and I'd like to see if there's a better way to have a more holistic discussion about how important cfg-attribute flexibility is first, especially as new features and syntax are added to the language that may need to account for cfg support.

So, what's the best course of action here?

2 Likes

Except for range and binop expressions where outer attributes are expressly forbidden for being problematically ambiguous, I think most if not all cases of cfg not even being allowed unstable are incidental and would be reasonable to just fix with a PR without an RFC (though unstably), so long as it doesn't adversely effect syntax error recovery.

Somewhat interestingly, the generic args case currently parses "successfully" as a const generic expression argument (i.e. something::<u32, { #[cfg(condition)] i32 }> and errors noting the lack of required curly braces. The unfortunately poor error illustrates the unintentionality of the current behavior:

error: invalid const generic expression
 --> src/lib.rs:3:40
  |
3 |     something::<u32, #[cfg(condition)] { i32 }>(1, #[cfg(condition)] 2); // Not OK!
  |                                        ^^^^^^^
  |
help: expressions must be enclosed in braces to be used as const generic arguments
  |
3 |     something::<u32, #[cfg(condition)] { { i32 } }>(1, #[cfg(condition)] 2); // Not OK!
  |                                        +         +
2 Likes

My intuition here is basically, "if it is punctuated by a(n implicit) comma, then it can be decorated with an outer attribute". Does that seem reasonable to apply generally? Or even, would that be worth codifying somewhere in a guideline to permit PRs to do that work? Expressions and blocks and the like are already pretty well supported from my experience, it's more about things in syntactic collections.

As a note, I'm fairly new to this process and am only familiar with the RFC path for proposing changes like this. If there's another more effective route then I'd be all for it, though perhaps seeing an example of what you mean could be helpful?

EDIT: Oh, and wow, you're totally right. This is legal (albeit with warnings):

fn something<T0, const N: usize>(a: T0) {}

fn main() {
    something::<u32, { #[cfg(all())] 3 }>(1);
}

In fact, you get a warning suggesting that you remove the brackets, which then produces an error if you do so. Very odd cases here that I would certainly like to fix.

My default answer here is that we should allow attributes on blocks, but then default to not allowing them on other arbitrary expression bits.

At the absurdity end, I really don't want a #[foo] + b to be legal, since it's very unclear to me what that's supposed to do.

Where's the right spot in the middle? I don't know.

There are other block-like constructs where they would be very useful too (even if on one the block delimiters are optional)

#[throws] async { foo().await?; }
bar(#[inline(always)] |x| x + 2);

One weird one though is else, should something like this be legal :thinking:

if foo() {
  bar();
} #[cfg(feature = "baz")] else if baz() {
  foobar();
}

Some of these can already be done, and you can at least use cfg-attributes to achieve most of these things without too much pain.


bar(#[cfg(all())] |x: u32| x + 2);
bar(#[inline] |x: u32| x + 2);

These both compile. I'm not sure what the inline guarantees are for that though. This would fall under my "if it is punctuated by a(n implicit) comma, then it can be decorated with an outer attribute" rule above, since this is a comma-terminated function argument.


#[throws] async { foo().await?; }

This one is not supported. For cfg-attributes you can do this because it's a block:

#[cfg(all())]
async { 
    println!("asdf")
};

If you try to use throws there, it gives you "cannot find attribute throws in this scope". That's a separate issue from this topic, I think, but it would be good to support that.


if foo() {
  bar();
} #[cfg(feature = "baz")] else if baz() {
  foobar();
}

This can't be done, but you can do this:

if foo() {
    bar();
} else {
    #[cfg(feature = "baz")]
    if baz() {
        foobar();
    }
}

Which is at least close enough I think. Though I'm certainly not opposed to the idea of adding cfg-attributes to else because this isn't a perfect replacement in all cases and control flow starts getting very weird if you branch or cfg-gate any more than this.

Where's the right spot in the middle? I don't know.

I agree we don't want to cfg-gate parts of expressions like that. Do you see anything wrong with the guideline I proposed above? Namely, "if it is punctuated by a(n implicit) comma, then it can be decorated with an outer attribute", where that applies to the whole lexical fragment in question. That's the intuition I have from the cases that do work, generally, for things like function arguments, struct fields, and so on. Note that this is in addition to some other rules/guidelines, such is the one specifying that blocks can always(?) be decorated.

Only in some positions, in other positions it's unstable.

error[E0658]: attributes on expressions are experimental
   |     let clos = #[inline] || {};

I wouldn't be surprised if this is a back-compat hack for accidental stabilization of #[inline] on closure arguments, custom attributes are not allowed even in that position, behind both a custom-attribute specific feature-gate, and the general attributes-on-expressions feature gate

error[E0658]: custom attributes cannot be applied to expressions
error[E0658]: attributes on expressions are experimental
   |     foo(#[culpa::throws] || {});

(I was more responding to scottmcm's talk about general attribute support, not specifically just #[cfg], I have some proc-macro-attributes I would love to be able to apply to both forms of closures instead of having to have a separate proc-macro for them).

2 Likes

Ah, I see. Yes, allowing cfg-attributes on expressions is tricky because it's almost certainly a compile error if the attribute evaluates to false. That said, there are other outer attributes, as you mention with #[inline] and #[throws] for which that is not the case and that could make sense to allow on expressions.

Grouping all of these together under the outer attribute umbrella and trying to determine permissibility at such a high level seems problematic when they all have different applications and use cases. I think it would make the most sense to be more broadly permissive with outer attributes as a grammatical construct and then be more strict about which types apply where.

I've put together a list of all of the comma-terminated fragments I'm aware of in the language and whether or not they can be decorated with a cfg-attribute. It's up on a HackMD page here. Let me know if there are any I've missed. Looking at this list I don't see a strong reason for why any of the currently excluded cases should continue to be that way, but I'm curious if anyone else has thoughts. I'm almost tempted to make a "can you use #[cfg] here?" quiz with these examples.

As an aside, I'm not fully up to speed on all of the vocabulary here, so if I'm calling something by the wrong term or name please let me know.

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.