Skip to content

Field-typed receivers are not inherited: a field declared on a superclass types nothing in a subclass (Java, ObjC, C#) #3151

Closed

Description

@xiongjianxu

Summary

The field -> TypeName tables that type a this.<field> / [self.<field> …] receiver are looked up
by the declaring class only, with no walk up the inherits chain. A field declared on a
superclass therefore types nothing in a subclass, and every member call through it is dropped — even
though the inherits edge is sitting in the same graph and the parent's field table in the same run.

Reproduced in Java and Objective-C; the lookup in C# (csharp_field_types) has the same
shape. Not a cross-file data problem: Java fails even when parent and child are in one file.

graphifyy 0.9.51 (v8), Python 3.10, macOS 14.

Reproduction — Java, one file

// src/lib/Greeter.java
package lib;

public class Greeter {
    public String greet(String who) {
        return "hi " + who;
    }
}

// src/lib/Pair.java
package lib;

class PBase {
    protected Greeter greeter = new Greeter();
}

public class Pair extends PBase {
    void run() {
        this.greeter.greet("same-file-inherited");
    }
}
calls   : []                                              <- expected Pair.run -> Greeter.greet
inherits: [('src_lib_pair_pair', 'src_lib_pair_pbase')]   <- the link is right there

Reproduction — Java, own field vs inherited field, same corpus

Direct declares its own field; Sub inherits an identical one from Base. Four files,
src/lib/{Greeter,Base,Sub,Direct}.java:

public class Base   { protected Greeter greeter = new Greeter(); }
public class Sub extends Base { void run() { this.greeter.greet("inherited-field"); } }
public class Direct { private Greeter greeter = new Greeter();
                      void run() { this.greeter.greet("own-field"); } }
$ graphify update . --no-cluster
calls:
  src_lib_direct_direct_run -> src_lib_greeter_greeter_greet  [INFERRED L7]
inherits:
  src_lib_sub_sub -> src_lib_base_base

Direct.run resolves, Sub.run does not. One call site out of two, differing only in where the
field was declared.

Reproduction — Objective-C

Same four-class shape (Greeter, Base owning @property Greeter *greeter, Sub : Base messaging
[self.greeter greet], Direct with its own property doing the same):

calls   : [('src_direct_direct_run', 'src_greeter_greeter_greet', 'INFERRED')]
inherits: [..., ('src_sub_sub', 'src_base_base')]

Note this must be run through extract() with relative paths — via the CLI both call sites
resolve to nothing for the unrelated reason in #3150.

Root cause

Java / C# — extractors/engine.py:4856-4871, a flat per-class lookup:

java_receiver_types = {
    body_id: _java_method_receiver_types(method_node, source,
                                        java_field_types.get(class_nid, {}))
    for body_id, (method_node, class_nid) in java_method_scopes.items()
}
csharp_receiver_types = { ... csharp_field_types.get(class_nid, {}) ... }

java_field_types is filled at engine.py:3896 keyed by parent_class_nid (the declaring class).
Nothing consults the superclass, so the stamped receiver_type on raw_calls is simply absent and
_resolve_java_member_calls bails at extract.py:3613-3615 (if not type_name: continue).

ObjC — extract.py:3681-3697 merges objc_field_types into field_types_by_class keyed by class
nid, and the receiver lookup uses the enclosing class' entry alone. Same omission, one layer up.

For Java/C# there is a structural wrinkle: the table is consumed inside the per-file extractor, so
a superclass in another file is not even in scope there. The Java 4-file case above therefore has two
causes stacked; the same-file case isolates the walk itself.

Suggested shape

  1. Do the walk in the cross-file pass, where both the tables and the inherits edges exist:
    • ObjC: at extract.py:3681, after field_types_by_class is merged, fold each class' table with
      its ancestors' (child entry wins on conflict).
    • Java/C#: export the per-class field tables from the extractor the way ObjC already exports
      objc_field_types, then fold + stamp receiver_type in _resolve_java_member_calls /
      _resolve_csharp_member_calls for the this.<field> receivers left untyped.
  2. Precedence: nearest declaration wins (a subclass field shadowing a parent's is the language
    semantics in all three). Keep the existing conflict rule for same-level collisions
    (extract.py:3689-3697 drops rather than guesses).
  3. Guard the walk against cycles and against a missing/out-of-corpus ancestor (stop, don't guess) —
    an ancestor whose type is not in the corpus should leave the receiver untyped exactly as today.
  4. The single-definition guard on the resolved type stays untouched; this only widens receiver
    typing, not target selection.

Happy to send a PR for the ObjC half (self-contained) and, if the export shape is acceptable, the
Java one.

Impact

Measured on a private 26-repo Objective-C/Swift codebase (0.9.39, built via extract() so #3150 was
not in play): of 44,434 [self.<field> …] message sends, 5,600 (12.6%) have a receiver whose
field is declared on an ancestor class and are dropped for this reason alone. That is one iOS
codebase, not a public-corpus figure, but the ratio is a property of how much inheritance the code
uses rather than anything project-specific.

Note

Two siblings from the same investigation: #3150 (ObjC field-table keys stale after the id remap, so
self.<field> typing is inert via the CLI) and a separate report on the receiver type being discarded
at resolver bail-out, which is what blocks cross-repo call edges after merge-graphs. Each is
independent — this one is the narrowest.

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