[RFC] Make `-Wreturn-type` an error *by default*

This RFC proposes making the -Wreturn-type warning an error by default (i.e. it can still be downgraded to a warning by passing -Wno-error=return-type).

Background

Falling off the end of a function is UB in C++ and C (though in the latter case only if the return value is actually used), in contrast to nearly every other modern ahead-of-time compiled language where this is a hard error. The status quo in C and C++ isn’t exactly desirable: there is rarely a case where actually falling off the end of a function that is supposed to return a value is desired behaviour. If that ever does happen, it tends to be a horrible bug that is difficult to track down.

The problem of false positives

Unfortunately however, it seems that the solution to this issue isn’t as simple as ‘just making it an error’. The main problem is that the CFG analysis required to determine whether a function returns on all control paths is far from perfect. Consider:

int f(int x) {
    if (x == 1) return 1;
    if (x != 1) return 2;
}

If you examine this function carefully, it should be clear that there can be no value of x for which f doesn’t return. Clang, however, doesn’t know that and issues a -Wreturn-type warning regardless.

Fixing this and similar cases is at best non-trivial and at worst impossible: reducing false positives in a systematic manner would require implementing what would essentially boil down to some strange version of InstCombine in the frontend, which is something that in my opinion is neither feasible nor desirable—both because I doubt anyone here wants to implement and maintain something like that for a single warning, and because doing this in the frontend kind of defeats the point of having an IR-based compiler framework; maybe a CIR-based solution will be able to ameliorate this situation at some distant point in the future by deferring this sort of analysis to a later compilation stage, after some amount of folding has already taken place, but for now, this categorically just isn’t viable, and I’m sure it would come with its own problems. In any case, the fact remains that what we’re trying to determine here is whether an arbitrary function will always return given arbitrary input. At the end of the day, we just end up at the halting problem.

In other words, resolving all false positives for this is impossible, and resolving any non-trivial false positives is infeasible. So we’re stuck with the false positives, and any discussion around making this warning an error (by default) needs to account for the fact that Clang will reject semantically valid programs.

Should we care about false positives?

At the risk of sounding a bit dismissive—and I doubt this really needs to be pointed out—but this isn’t a problem that’s specific to Clang, or C, or C++. Almost every other statically-compiled high-level language necessarily suffers from the same problem, and typically treat the equivalent of our function f above as a hard error (there are some exceptions: e.g. Ada only issues a warning, and Fortran doesn’t care at all).

That is, they all run into the same false positives, and they all seem to agree that allowing one to not return a value from a function is not defensible in a modern programming language. The fact that this is at least a warning in Clang which is enabled by default and that there are many people who use assert(false), __builtin_unreachable(), and similar facilities to convince the compiler that some code path is, in fact, unreachable rather than turning off the warning is a pretty good indication that falling off the end of a function is categorically a bug. Really, the only reason this isn’t an error by default is for historical reasons: if anyone was to design C or C++ today, I doubt they wouldn’t make this a hard error.

Now, of course, it’s easy to say that ‘anyone who writes code like our f above is just doing it wrong; they should just fix their code’, but making something the user’s problem and breaking everyone’s code is not something that we like doing, and for good reason. While you can argue that many (if not most) cases of falling off the end of a function are probably a bug, there may well be cases out there that are genuine false positives in code bases that have been well tested and which are known to work, and the only thing we’d be doing to those is suddenly break their code ‘for no reason’—at least from their point of view.

The main problem here, then, is what do we do about all the existing code that nobody touches but just compiles over and over again. If this change truly does start breaking code that simply cannot be updated or even have its compiler flags changed, then that would really make this change untenable. The question is whether any such code actually exists or whether these are hypotheticals that we’re worrying about for nothing, and I’m not sure there is a good way of finding this out other than trying to make the change and see if anyone starts complaining about it—unless anyone here happens to know a code base that would be affected by this.

A possible compromise, then, would be to only make this an error in newer language modes (e.g. C++23/26 and perhaps C23/2y); this is more likely to have the desired effect (viz. new code benefits from this being an error, and old code isn’t affected). While it is still possible to argue that this might impede the adoption of e.g. C++23 in an affected code base, I should hope that anyone who is updating their code base to compile under a modern standard such as C++23 is also willing to update their code proper if it stops compiling as a result—or at the very least, if they’re already changing one compiler flag, they can at the very least also add another and pass -Wno-error=return-type as a last resort.

Addendum: switch statements

A special case that is worth mentioning here is:

enum E { A, B };
int g(E e) {
    switch (e) {
        case A: return 1;
        case B: return 2;
    }
}

I’m bringing up this case because this is actually one that other languages with more strongly-typed enums or discriminated unions tend to accept (via exhaustive pattern matching), because they generally don’t let you cast any random number to an enum type. Unlike the perhaps a bit contrived f above, this is actually a fairly reasonable pattern. One option here is of course to just put e.g. __builtin_unreachable() or assert(false) or something similar after the switch statement, but as it turns out, Clang actually doesn’t diagnose this either—not even if you pass -Wall -Wextra -Werror=return-type. There are additional flags you can pass to make us diagnose this (which you might want to enable if casting to enums is commonplace in your code base), but this is an exception where we have a pattern that is reasonably common to where it makes sense to consider this well-formed (because it is, assuming no-one ever casts anything to E).

Thus, notwithstanding everything that was pointed out earlier, we can definitely add support to the CFG for common patterns such as this one whose ill-formedness is more contentious—it’s just that we can’t support all possible patterns, and there will always be false positives. And while you can at least make a good case for this g here, our f above is not so much of a ‘pattern’ but really just an antipattern that isn’t worth supporting: the if (x != 1) is tautological and thus useless; you can just remove it and write either else return 2; or just return 2;, both of which we don’t warn about.

Conclusion

Defaulting -Wreturn-type to an error has its share of problems, but I’d argue that it is worth pursuing. I can think of a few possible ways in which we could approach this (see below).

We should also decide whether we want to treat C and C++ differently here—not just because falling off the end of a function isn’t always UB in C, but also because C++ devs tend to be more accepting of their code breaking all the time than C devs.

Finally, thanks also a lot to @AaronBallman for helping me w/ this RFC!

Possible approaches

  • Option 1: Make this an error by default in all language modes.
  • Option 2: Make this an error by default starting in some reasonably modern language mode (we can decide on one later, but one of C++20/23/26 and C23/2y seems reasonable to me).
  • Option 3: Make this an error in clangd so only people writing new code are told to ‘do better’ more aggressively and revisit this discussion in a few years.
  • Option 4: Do nothing. :frowning:
2 Likes

As I mentioned on the PR for this, my biggest concern with this is that the false positives are going to be very disruptive.

We got ~10 separate reports (with plenty of add-on comments of others hitting similar issues) about the false positives with -Wreturn-type. That’s a significant amount of reports. So I expect users will hit these false positives often enough on correct code that they’re going to be frustrated. That will either translate into folks avoiding upgrading Clang, disabling the diagnostic entirely, or (if we go that route) not updating to the latest standard version.

It’s a matter of style as to whether this code is “bad” enough to diagnose at all, let alone as an error:

int foo(int i) {
  if (i > 0)
    return 12;
  if (i <= 0)
    return 1;
}

and I worry that making this an error by default is going too far.

2 Likes

I agree with many things that are said in RFC. I also understand the significance of 10 separate reports about false-positives that Aaron found on the bug tracker, and agree that we need to execute caution.

Instead of framing this RFC as flipping one of the switches outlined in possible approaches, I think this needs to be a transition, where we suggest users to annotate their code with e.g. assert(false) or (implicitly) ask them to reconsider in general (by issuing the diagnostic). Fixits are an obvious vehicle for that.

After such a transition period, we should be in a good position to go with option 1.

I can see how option 2 might sound reasonable, but it comes with a concern. Let’s say we make it a warning that defaults to an error in C++26 and newer modes. It would make sense (and smooth the transition) to issue a compatibility warning in older language modes. Typically such forward compatibility warning are backed by a change in the Standard, but not in this case. Do we have a precedent for this? If not (or we don’t want to repeat that), I’d be compelled to ask author to submit a paper to WG21.

Option 3 is an interesting idea, but I’d like it to explicitly say something along the lines of “this will be an error in a future version of Clang”. Otherwise user are going to observe that Clang accepts code that clangd complains about, and their trust in clangd will decrease.

Option 4 is what we pick if we don’t reach consensus on other options, and I think doing nothing would be unfortunate.

If we want C++ eventually to be a “safer” language then these are the types of changes we are going to need to embrace.

I don’t want to trivialize Aaron’s or Vlad’s concerns, they are :100:valid but I do believe if we are going to make any progress in getting to a safer language, the implementations are the places were will need to do these trials and take some risk. I do think we need to be cautious but we also need a bit of bravery here as well. We have the option of finding a middle road that gets a closer while giving folks the time to migrate their code.

I think the implementation really need to take the initiative here and then use that as feedback into the standardization process. We as the implementations can move faster and nimbler and then give feedback to the process, “Hey, we made this work and it had these benefits” or “We tried this and it went bad and this is what we learnt about how to approach this”.

3 Likes

I’m a little skeptical that “error by default” will achieve much here other than potentially trigger false positives in old software. It’s already “warning by default”, and in actively-maintained software, the maintainers are testing with -Werror already, right?

1 Like

We discussed this during my office hours this morning (involved were me, @cor3ntin, @Endill, @Sirraide, and @shafik) and we started coming around to the following:

  • We want a fix-it for -Wreturn-type which inserts some sort of unreachable marking (similar to how fallthrough already works). This helps users silence the diagnostic in the case of false positives.
  • Due to false positives and disruption concerns, we want to leave -Wreturn-type a warning by default (at least for the time being). To enable it by default, we really need an analysis about the impacts – build a large distro worth of packages to see if the fallout might be acceptable, if it is, then land the changes on main and see if downstreams find more fallout we missed, etc.
  • We want to introduce -Whardened (or -fhardened, etc) which then opts into stronger diagnostic behavior (this requires its own RFC, note that GCC has a flag for this, it impacts libc++, etc).
4 Likes

The reason why this tends to be horrible to debug (if you miss the diagnostic, for whichever reason) is because the compiler can egregiously “miscompile” the function when the programmer makes this mistake (in C++). I assume this is known here, and this is your motivation.

For completeness, I think there is also the option that the compiler does not exploit this undefined behavior. It turns out it was already proposed that clang should emit something other than unreachable here (unless you mark it explicitly, e.g. with std::unreachable). See clang++ should always emit `@llvm.trap()` when flowing off the end of a non-void function · Issue #75797 · llvm/llvm-project · GitHub which claims “This [=emitting unreachable] offers no optimization opportunities that could not be trivially obtained with more explicit code”.

In general, is there any proposal in the standard committees regarding falling off the end of a non-void function now that the programmer can be explicit with things like [[noreturn]] and std::unreachable?

We discovered today that Clang already emits @llvm.trap in C++ mode (but not in C mode because it’s technically not UB in C if the return value isn’t used).

I think I favor the direction of making this a default-error-warning as proposed, and Aaron still left the door open to that. So, without second-guessing the decision you came to, I think it’s worth expanding on “build a large distro worth of packages to see if the fallout might be acceptable”.

Community members have taken on this level of integration testing before, but we’ve never documented how to do this. I seem to recall that @sylvestre had some system that automatically rebuilt Debian with nightly clang packages to find these kinds of breakages, but I may have hallucinated it. Are there some best practices we can document to enable us to test potentially breaking changes on the Linux distro ecosystem? Even manual, non-automated processes, like “set up this VM and rebuild distro packages like this manually” would be helpful.

Clearly, for this kind of diagnostic change, you need some form of compiler wrapper to log every error and keep building by disabling warnings or continuing the build by falling back to an old compiler or something like this.

2 Likes

I do think we should at least try to some capacity to see if we can make it an error by default, e.g. by doing this and seeing if everything explodes. (Of course, we should introduce -fhardened/-Whardened either way though.)

I think Step 1 for that is to try to build a large distro of packages as a smoke test. If we’ve done that and not found it a massive disruption, then I think it’s fine to try it on downstreams as well by landing changes well before we branch for a release. But without having done that, I’m wary of how disruptive it will be for downstreams.

1 Like

The error on falling off the end of functions is great. The usual behaviour on x64 when you get that wrong is a variant on “illegal instruction” iirc and other targets can be less forgiving.

Case analysis seems to go thus:

  1. Compiler knows the bad path is reachable and user has made a mistake
  2. Compiler knows the bad path is reachable and the user knows it is not
  3. Compiler thinks the bad path might be reachable but isn’t sure

That we have false positives on this means we’re warning when we think the bad path is reachable, as opposed to only when we’re sure it is. That’s useful for a warning but the wrong thing for an error.

I would say this warning should not be promoted to an error because that will change us from correctly compiling correct code to refusing to compile the same code, and users will rightly yell at us for that.

However there is a place for a similar error when the compiler is sure that badness can happen. Possibly under a different flag. Behaviour would be to refuse to compile code that can walk off the end, as opposed to on code that we’ve failed to analyse completely.

The current status is clang gets this wrong and I insert __builtin_unreachable to shut it up, which I don’t love, but I really would not want to be the person compiling entire linux distributions with their own collection of build scripts that now needs to work out how to insert -Wno-error=return-type into all those packages, nor do I want to explain to that developer that we knew about the false positives and inflicted this on them anyway.

(case 1 is bad programmer, case 2. is when the bad path never happens on data the program ever sees, which is a bit niche, case 3 is where the CFG is complicated)