Skip to content

A concrete base method with a modifying override stays non-modifying; virtual callers inherit it #116

Closed

Description

@bnaudts

fernflower (test-oss/fernflower, main sources; maddi 8a38863c8, maddi-mod 6ed8f49f):

// Exprent
public void inferExprType(VarType upperBound) { }                 // empty: a hook for subclasses

// FieldExprent extends Exprent
@Override public void inferExprType(VarType upperBound) {
  ...
  inferredType = ft.getSignature().type.remap(...);                // modifies this
}

// AssignmentExprent extends Exprent
private Exprent left;
@Override public void inferExprType(VarType upperBounds) {
  left.inferExprType(upperBounds);                                 // virtual: left is often a FieldExprent
}

Results: FieldExprent.inferExprType modifying (right). Exprent.inferExprType has nonModifyingMethod: 1, and its
implementations list includes FieldExprent.inferExprType. AssignmentExprent.inferExprType has
nonModifyingMethod: 1 too, although assigning to a field calls FieldExprent.inferExprType on its own left.

So a call through a concrete, overridden method takes the base body's verdict. The fold over implementations that
abstract methods get does not seem to apply when the base method has a body. For a call whose receiver is not known
exactly, the verdict should be the base body's joined with the overrides' (within the analysed sources), here
modifying. Six classes override inferExprType in fernflower. The empty-hook-in-a-base-class shape is common enough
that this is likely not the only case.

🤖 Generated with Claude Code

Activity

  1. bnaudts commented on Nov 12, 2026

    @bnaudts
    Author

    A stronger case of the same, Exprent.replaceExprent:

    // Exprent
    public void replaceExprent(Exprent oldExpr, Exprent newExpr) { }      // overridden in 10 subclasses, which modify
    
    // StackVarsProcessor
    private static void replaceSingleVar(Exprent parent, VarExprent var, Exprent dest, SSAUConstructorSparseEx ssau) {
      parent.replaceExprent(var, dest);
      ...
    }

    Exprent.replaceExprent is non-modifying (the empty body), and replaceSingleVar has parent as unmodifiedParameter: 1, although its first statement replaces one of parent's operands whenever parent is anything but a bare Exprent. InvocationExprent.replaceExprent, FunctionExprent.replaceExprent and ExitExprent.replaceExprent are all modifying, and their callers through Exprent (ConcatenationHelper.simplifyStringConcat, StackVarsProcessor.iterateChildExprent) keep their parameters unmodified too.

    🤖 Generated with Claude Code

  2. bnaudts commented on Nov 13, 2026

    @bnaudts
    Author

    Decision (Bart): option A. A non-modifying verdict may not stand when an override modifies. The dispatch union is now on by default; NODISPATCHUNION=1 restores the old behaviour (WIP 933606d on ws/maddi-null). TestOptimisticModificationShapes: O2-D keeps the old behaviour as the gated variant, and o2dUnion pins the default. Corpus A/B pending.

  3. added 2 commits that reference this issue on Nov 13, 2026
  4. bnaudts commented on Nov 13, 2026

    @bnaudts
    Author

    Fixed on devel (merge 00b2a40) as decided (option A: a @notmodified verdict is not overridden by a modifying override; the base is the union over its implementations): 933606d (dispatch union on by default, gate NODISPATCHUNION). Gate green on devel after the merge (laser1, 2026-11-13).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions