Reverse engineering trait implementation to fix the Vx's

TL;DR: In Vx, two implementations of the same trait produced identical symbol names when they differed only by parameter type. Resolving calls between them seemed to require C++-style overload resolution. Testing the pattern in Rust revealed a simpler solution. The compiler infers the argument type first, leaving only one matching implementation. Untyped integer literals resolve through integer fallback to i32. Fixing the bug in Vx took two steps: encoding trait arguments into mangled symbol names so implementations stay distinct, and narrowing candidate implementations by argument type at call sites. Because Vx already defaulted untyped integer literals to i32, the fallback behavior worked immediately.

The most productive hour of this fix was writing forty lines of Rust and inspecting what rustc emitted.

Two implementations, one mangled name

In Vx, implementing the same trait multiple times for one type caused a symbol name collision. Compilers assign internal identifiers called symbol names (or mangled names) to functions so the linker can tell them apart. Vx generated method symbols by joining the receiver type (the type implementing the method) and the method name: <receiver>$<method>.

This scheme omitted the trait and its type arguments. Multiple implementations of the same trait with different argument types produced identical symbols:

impl From<i32> for i64 { .. }   // i64$from
impl From<u8>  for i64 { .. }   // i64$from, again

Because the symbols collided, a deduplication pass dropped the second implementation before emitting its code. Call sites then failed because two functions shared one name.

This collision blocked work on core::convert. That module defines widening conversions for primitive integers by generating From implementations across integer types.

Why this looked like a language design problem

Fixing the symbol names was straightforward, but deciding which method to call at a call site looked difficult. Consider this call:

i64::from(7)

Candidates exist for both i32 and u8. Which implementation should the compiler choose? The literal 7 fits into both types.

This problem seemed to demand overload resolution. In languages like C++, overload resolution inspects multiple functions with the same name. It scores conversion ranks, picks the best match, and applies tie-breaking rules. Adding full overload resolution changes the core semantics of a language, and implementing it correctly is difficult.

Before designing a complex resolution system for Vx, I checked how Rust handles this exact pattern.

Testing the pattern in Rust

To see how another language handles this, I wrote a test program in Rust. Rust uses an orphan rule, which prevents user code from implementing an external trait for external types. Because both From and i64 belong to the standard library, the test uses a local trait named MyFrom. Each implementation returns a distinct integer:

trait MyFrom<T> { fn my_from(v: T) -> Self; }

impl MyFrom<i32> for i64 { fn my_from(_v: i32) -> i64 { 32 } }
impl MyFrom<u8>  for i64 { fn my_from(_v: u8)  -> i64 { 8 } }

fn main() {
    println!("literal      -> {}", i64::my_from(7));
    println!("typed u8     -> {}", i64::my_from(7u8));
    let n: u8 = 7;
    println!("u8 variable  -> {}", i64::my_from(n));
}

The program compiles without error and runs:

literal      -> 32
typed u8     -> 8
u8 variable  -> 8

The untyped literal selects the i32 implementation, while explicit types select their matching implementation.

Next, I inspected the compiled object file with nm, a command-line tool that lists symbol names in compiled binaries:

__RNvXCs..._1b  x  INtB2_6MyFrom l E  7my_from     <i64 as MyFrom<i32>>::my_from
__RNvXs_Cs..._1b x INtB4_6MyFrom h E  7my_from     <i64 as MyFrom<u8>>::my_from

Rust's v0 symbol mangling format encodes the receiver type, the trait name, and the trait arguments into each symbol. In these names, x represents i64, while l and h represent i32 and u8. Changing u8 to u16 changes h to t. By including trait arguments in mangled names, Rust gives every implementation a distinct symbol.

I also tested what happens when no implementation matches the default integer type. Compiling the program with implementations for only u8 and u16 produces this error:

error[E0277]: the trait bound `i64: MyFrom<i32>` is not satisfied
help: the following other types implement trait `MyFrom<T>`
      `i64` implements `MyFrom<u8>`
      `i64` implements `MyFrom<u16>`

Rust reports an unsatisfied trait bound (a requirement that a type implement a trait). The compiler assigns i32 to the literal, checks whether i64: MyFrom<i32> exists, fails, and lists the available implementations.

How type inference avoids overload resolution

Rust avoids overload resolution by combining type inference with fallback rules. When the compiler checks i64::my_from(7), it creates a trait obligation: i64: MyFrom<?T>. A trait obligation is a requirement that a type implement a trait. The placeholder ?T is an inference variable, an unknown type that the compiler still needs to solve. Once the compiler determines the argument type, it replaces ?T with a concrete type. That concrete type matches exactly one implementation, so the compiler never has to rank candidate methods.

Untyped integer literals rely on integer fallback. When an integer inference variable remains unresolved at the end of type checking, the compiler defaults it to i32 and re-checks the trait obligation. This fallback rule eliminates the need for an overload resolution subsystem.

This pattern simplified the fix in Vx because Vx already defaults uncontexted integer literals to i32. An uncontexted literal is a literal without an explicit type hint, such as let x = 7;. Because Vx already had this fallback rule, method resolution only needed to infer argument types before looking up implementations.

The working program in Vx

With this fix, Vx resolves calls to multi-implementation traits by argument type. The pull request adds a test fixture to verify this behavior. Each implementation returns an identifier number, and print writes numbers without separators:

trait Build<T> {
  fn make(v : T) -> Self;
}

impl Build<i32> for i64 {
  fn make(v : i32) -> i64 {
    return 1;
  }
}

impl Build<u8> for i64 {
  fn make(v : u8) -> i64 {
    return 2;
  }
}

impl Build<u16> for i64 {
  fn make(v : u16) -> i64 {
    return 3;
  }
}

fn main() -> i32 {
  // No annotation: the literal is i32, so the i32 impl runs.
  print(i64::make(7));
  let b : u8 = 3;
  print(i64::make(b));
  let s : u16 = 9;
  print(i64::make(s));
  return 0;
}

Running the program outputs 123.

If fallback selects a type that lacks an implementation, Vx mirrors Rust by listing the available implementations in an error message:

Error[E3036]: No impl of 'make' on 'i64' takes (i32); 'i64' implements Build<u16>, Build<u8>

What went wrong: breaking scalar math

My first implementation broke the standard library's scalar math by mangling trait names onto every trait method. In Vx, abs on f32 comes from impl Math for f32. Mangling the trait name renamed every math method in the standard library, breaking roughly forty test fixtures.

Updating forty test fixtures would have preserved a bad rule. A trait without type arguments can be implemented only once for any given type. Its methods can never collide with themselves. Two implementations of the same trait for one type can differ only when the trait takes type arguments. Therefore, the mangler only needs to include trait details when a trait accepts type arguments.

Under this revised rule, impl Math for f32 keeps the simple symbol f32$abs, while impl Build<i32> for i64 becomes i64$Build$i32$make. If two different traits provide the same method name for a type, the compiler still flags that collision at the call site.

What the test suite caught and grep missed

Automated test fixtures caught regressions that manual searches completely missed. Six fixtures pinned the old symbol names for methods on argument-bearing traits. Running the test suite caught all six fixtures, whereas three separate manual searches across the repository missed every one.

During the fix, four manual checks produced false confidence by reporting success when they should have failed:

Each check returned a successful result without performing a valid test. A check that cannot fail provides no useful signal. Silent pass states are the most dangerous testing trap to eliminate.

Measuring existing compilers

Studying Rust saved significant language design effort. Deciding how a language resolves calls is costly to implement and difficult to change later. Rust solved this problem a decade ago. Writing forty lines of code and running nm replaced open-ended language debate with an empirical measurement.

The investigation also produced a much simpler implementation. The compiler only needs to infer argument types, select the single matching implementation, and apply the existing i32 fallback for untyped integer literals.

The change is Vx#709, the issue is Vx#686, and the Rust findings are documented in a comment on the issue. Instance dispatch currently does not narrow by argument type; that follow-up task is explicitly recorded in test fixtures. Vx is licensed under Apache 2.0 with the LLVM exception (install it).