Okay, I think I've figured out what was going on. Compilers do not implement Prosser's algorithm, period. Even though Prosser's algorithm does resolve the particular known defect in the language specification, it doesn't explain the way actual compilers expand macros.
Instead of hide sets, there seem to be two kinds of disabling bits in play: a per-macro disabling bit, and a per-token disabling bit.
The per-macro disabling bit for a macro M
is set during the the rescan phase whenever M
is being expanded, and is cleared once expansion of M
is complete.
If at any point while M
is disabled the preprocessor processes an instance of M
(whether or not syntactically valid for expansion), it marks that particular M
token as disabled. Not only does the compiler avoid expanding a token when it disables the token, it also permanently disables any consideration of the token for expansion, in any context, even after M
's expansion has finished.
So let's look at some even simpler examples:
#define ID(arg) arg
#define LPAREN (
#define F_AGAIN() F
#define F() f F_AGAIN LPAREN)()
F() // => f F_AGAIN ()()
ID(F()) // => f f F_AGAIN ()()
ID(ID(F())) // => f f f F_AGAIN ()()
#define G() g G LPAREN)()
G() // => g G ()()
ID(G()) // => g G ()()
ID(ID(G())) // => g G ()()
First consider what is happening in the expansion of G()
. It expands to the token list g G LPAREN)()
and during the re-scan, the G
in that token list is permanently disabled. Now no matter how many times you re-scan the token list as a result of passing it through ID
, the G
can never be expanded.
Next consider what is happening with F()
. It expands to the token list f F_AGAIN LPAREN)()
. During the rescan, this gets expanded to f F_AGAIN ()()
. Because F_AGAIN
is not currently a disabled macro, none of these output tokens gets disabled. So now in any re-scan of the ID
macro, F_AGAIN
will get expanded once, also causing F
to be expanded once.
With that context, it's actually possible to make some sense of the language spec:
If the name of the macro being replaced is found during this scan of the replacement list (not including the rest of the source file's preprocessing tokens), it is not replaced.
Furthermore, if any nested replacements encounter the name of the macro being replaced, it is not replaced.
These nonreplaced macro name preprocessing tokens are no longer available for further replacement even if they are later (re)examined in contexts in which that macro name preprocessing token would otherwise have been replaced.
The part that I guess messed with my intuition is that "it is not replaced" sounds so innocuous--particularly in contexts where the macro would not be replaced anyway, for instance because it's a function-like macro not followed by an open paren (
. Then the passive voice in "tokens are no longer available for further replacement" makes it sound like it's just describing a consequence of the previous sentence, when really the spec means, "The compiler actively poisons that token and prohibits it from ever being expanded again."