Skip to content

Fix circular dependency in __traits(getAttributes, __traits(getMember, ...))#22541

Closed
CyberShadow wants to merge 1 commit intodlang:masterfrom
CyberShadow:fix-getAttributes-getMember-circular-dep
Closed

Fix circular dependency in __traits(getAttributes, __traits(getMember, ...))#22541
CyberShadow wants to merge 1 commit intodlang:masterfrom
CyberShadow:fix-getAttributes-getMember-circular-dep

Conversation

@CyberShadow
Copy link
Member

Generated by Claude Opus 4.6, usual disclaimers apply.


Problem

D's compile-time introspection makes it natural to build ORM-style schemas, serialization frameworks, and other metaprogramming patterns where a type's methods introspect its own members via UDAs. A typical pattern is a template struct (or mixin template) that iterates __traits(allMembers) to discover @Column-annotated fields, while also containing methods that reference the introspection results at compile time:

struct Column { string name; }

struct Table(T) {
    @Column("id") T id;
    @Column("data") T data;

    static string[] columnNames() {
        string[] result;
        static foreach (name; __traits(allMembers, Table)) {{
            static foreach (attr; __traits(getAttributes, __traits(getMember, Table, name))) {
                static if (is(typeof(attr) == Column))
                    result ~= attr.name;
            }
        }}
        return result;
    }

    auto save() {
        enum cols = columnNames();
        return cols.length;
    }
}

static assert(Table!int.columnNames() == ["id", "data"]);  // Error

This fails with:

Error: function `columnNames` circular dependency.
       Functions cannot be interpreted while being compiled.

The same issue occurs with mixin templates that introspect their host type's members — a pattern common in D ORM libraries.

Analysis

The root cause is that we sometimes want to read UDAs on symbols before those symbols are fully semantically resolved. The chain that produces the error is:

  1. static assert evaluates columnNames() via CTFE, which starts compiling its body (semantic3)
  2. The body iterates allMembers, which includes "save". For that member, __traits(getMember, Table, "save") runs full expressionSemantic on a DotIdExp
  3. This triggers functionSemantic on save()
  4. save() has an auto return type, so functionSemantic eagerly calls functionSemantic3 (body compilation) to infer the return type
  5. save()'s body contains enum cols = columnNames(), which tries to CTFE columnNames()
  6. But columnNames() is already being compiled — circular dependency

The fundamental issue is that getMember pulls in far more semantic work than what getAttributes actually needs. The full expressionSemanticfunctionSemanticfunctionSemantic3 cascade is necessary if you intend to call the member or inspect its type, but getAttributes only wants to read UDA metadata.

Observation

__traits(getAttributes) only needs the Dsymbol to read its .userAttribDecl — a field that is populated during parsing, long before any semantic analysis runs. It never needs a fully-resolved expression, a function's return type, or its compiled body. The expensive semantic work triggered by getMember is entirely wasted when the result is immediately passed to getAttributes.

Proposal

Add a short-circuit in the getAttributes handler that detects when its argument is an unevaluated __traits(getMember, T, "name") and resolves the member via lightweight sym.search() (the same mechanism __traits(hasMember) uses) instead of full expressionSemantic. This reads UDAs directly from the symbol table without triggering any function body compilation.

Implementation

A private helper getAttributesOfMemberFastPath is added to compiler/src/dmd/traits.d. It is called at the top of the getAttributes handler, before the existing TemplateInstance_semanticTiargs call:

  1. Pattern match: Check that the single argument is an unevaluated TraitsExp with ident == Id.getMember and exactly 2 arguments
  2. Cheap argument resolution: Call TemplateInstance_semanticTiargs on the inner getMember's arguments to resolve the type and string — this is lightweight (no body compilation)
  3. Extract member name: CTFE-interpret the second argument to get the string, convert to Identifier
  4. Get aggregate symbol: getDsymbol() on the first argument — trivial for TypeStruct/TypeClass/TypeEnum (returns .sym)
  5. Symbol table lookup: sym.search(loc, id) — pure symbol table lookup, no semantic analysis
  6. Read UDAs: sm.userAttribDecl.getAttributes() — runs arrayExpressionSemantic only on the UDA values themselves

The helper returns null at any step that fails, falling back to the existing full-semantic path. This ensures no behavioral regressions — if the fast path can't handle a case, the old code takes over transparently.

Trade-offs

This does introduce an inconsistency between the nested and two-step forms:

// Nested form (uses fast path — no body compilation triggered):
__traits(getAttributes, __traits(getMember, T, "name"))

// Two-step form (full expressionSemantic — may trigger body compilation):
alias m = __traits(getMember, T, "name");
__traits(getAttributes, m)

The UDA results are identical in both cases. The difference is only in side effects: the nested form no longer triggers eager body compilation of method members. Code that relied on getAttributes(getMember(...)) to force early body compilation (an undocumented side effect) would need to use the two-step form. In practice this is unlikely — the nested form is the idiomatic way to read UDAs, and triggering body compilation was never its intent.

…, ...))

Add a fast path for the nested `__traits(getAttributes, __traits(getMember,
T, "name"))` pattern that resolves the member via lightweight `sym.search()`
instead of full `expressionSemantic`. This avoids triggering
`functionSemantic` → eager `functionSemantic3` (body compilation) on method
members, which can cause "circular dependency: functions cannot be interpreted
while being compiled" errors in introspection-heavy code.

The issue occurs when a struct method iterates `__traits(allMembers)` and
reads UDAs via `getAttributes(getMember(...))`. On HEAD, `getMember` on a
method with `auto` return type triggers `functionSemantic3` for return type
inference, which compiles that method's body. If the body references the
introspecting function at compile time (e.g. `enum x = columnNames()`), a
circular dependency results because the introspecting function is already
being compiled.

The fast path falls back to the existing full-semantic path (returns null)
whenever the pattern doesn't match or the lightweight resolution fails, so
there are no behavioral regressions for other uses of `getAttributes`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dlang-bot
Copy link
Contributor

Thanks for your pull request, @CyberShadow!

Bugzilla references

Your PR doesn't reference any Bugzilla issue.

If your PR contains non-trivial changes, please reference a Bugzilla issue or create a manual changelog.

Testing this PR locally

If you don't have a local development environment setup, you can use Digger to test this PR:

dub run digger -- build "master + dmd#22541"

Copy link
Contributor

@dkorpel dkorpel left a comment

Choose a reason for hiding this comment

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

This is unmaintainable AI slop, a proper solution should be more general than a dedicated new code path just for this exact test case.

@CyberShadow
Copy link
Member Author

It was my idea 🙈

A more general path would mean that __traits getMember now returns something other than a fully resolved symbol. Any suggestions?

@CyberShadow
Copy link
Member Author

One obvious alternative is new dedicated getMemberAttributes, getMemberReturnType etc. traits, however this changes the language in order to arguably work around a compiler limitation.

@dkorpel
Copy link
Contributor

dkorpel commented Feb 9, 2026

One obvious alternative is new dedicated getMemberAttributes, getMemberReturnType etc. traits, however this changes the language in order to arguably work around a compiler limitation.

That would at least make it explicit that something special is happening, instead of giving special semantics to a magic combination which breaks at the slightest refactor, for example:

alias mem = __traits(getMember, Table, name);
static foreach (attr; __traits(getAttributes, mem)) { ...

And once a proper solution is implemented, getMemberAttributes could be deprecated, or be rewritten to take the more general path internally.

@CyberShadow
Copy link
Member Author

CyberShadow commented Feb 9, 2026

OK, let's do that. I will resubmit.

instead of giving special semantics to a magic combination which breaks at the slightest refactor

(FWIW, this was clearly stated in the "Trade-offs" section.)

@CyberShadow CyberShadow closed this Feb 9, 2026
@dkorpel
Copy link
Contributor

dkorpel commented Feb 9, 2026

A more general path would mean that __traits getMember now returns something other than a fully resolved symbol. Any suggestions?

I'm contemplating to what extend this kind of code should be supported. Introspecting all members of a type while inside that type (and potentially still defining that type) is always a minefield of possible contradictions, race conditions, or other edge cases. I recommend putting that kind of code outside the struct instead of mixing it in.

@dkorpel
Copy link
Contributor

dkorpel commented Feb 9, 2026

(FWIW, this was clearly stated in the "Trade-offs" section.)

(I'll be honest, I don't like reading long-winded AI texts)

@CyberShadow
Copy link
Member Author

I recommend putting that kind of code outside the struct instead of mixing it in.

I don't think this is going to help. The problem is that we're still recursing into it while in the middle of semantic analysis, so moving the code is not going to break the chain. The only alternative I'm aware of is to give up on the approach and design the application differently, i.e. give up on features that rely on D compile-time introspection such as strong typing.

(I'll be honest, I don't like reading long-winded AI texts)

Understandable, but to be honest it probably would not have been very different if I had written it out by hand.

The key point is that we are not OK with pattern-matching expression trees in order to elide problems such as recursive analysis errors. This was not something I was aware of, AI or not.

@CyberShadow
Copy link
Member Author

Introspecting all members of a type while inside that type (and potentially still defining that type) is always a minefield of possible contradictions, race conditions, or other edge cases.

From an ideological point of view, I agree in principle. The disconnect is that declaring the body of a method of a type should not count as part of declaring the type. Herein lies the problem at hand: one of the main tools we have to inspect a type is the getMember trait, which we have to use to inspect attributes of members, however it currently forces full semantic analysis of the body as well.

@CyberShadow
Copy link
Member Author

OK, let's do that. I will resubmit.

#22545

@dkorpel
Copy link
Contributor

dkorpel commented Feb 9, 2026

I don't think this is going to help. The problem is that we're still recursing into it while in the middle of semantic analysis, so moving the code is not going to break the chain.

The key is to make the methods that are mixed in not part of the introspection of the mixin. I could make your code snippet compilable by skipping save() for example:

- static foreach (name; __traits(allMembers, Table))
+ static foreach (name; __traits(allMembers, Table)) static if (name != "save")

@CyberShadow
Copy link
Member Author

Hmm, that's no good, in practice the two are going to be quite far away, and there might be legitimate cases to get the currently analyzed function's UDAs from outside due to a legitimate dependency chain anyway.

@CyberShadow
Copy link
Member Author

CyberShadow commented Feb 9, 2026

Could we make getMember just return an unresolved DsymbolExp and move semantic analysis to call site ?

@dkorpel
Copy link
Contributor

dkorpel commented Feb 9, 2026

That could work. Need to see how the test suite reacts to that, there's a chance it will break certain constructs.

@CyberShadow CyberShadow added the AI Generated Code that is generated by an LLM AI. label Feb 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Generated Code that is generated by an LLM AI.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants