When the build order changes the program

TL;DR — C++ generates a class's copy assignment, or a template's code, only in the files that use it, and each of those files makes its own copy. The linker keeps one. If the copies differ, the link order decides which one the program runs, and the standard does not require the compiler to say anything. Swift and Haskell have the same shape; Rust rules it out with three rules. Vx follows those rules in most places. This post shows where it does not yet.

A compiler should give the same program for the same source, however the build is ordered, split or run in parallel. The code a C++ compiler generates only on use is where that promise breaks.

Code made where it is used

C++ does not generate code nobody calls. A class's implicit operator= is defined only when something assigns one of its objects. A template's body is instantiated only for the types it is used with. Both are generated in each file (translation unit) that uses them, as inline functions, and every one of those files puts its own copy in its object file. The linker keeps one copy and drops the rest.

That is fine while the copies agree. Nothing makes them agree. Here a header is compiled twice with different settings, which a large build does by accident more often than anyone would like:

// buffer.h
struct Buffer {
#ifdef LARGE
  char bytes[4096];
#else
  char bytes[64];
#endif
};
inline int capacity() { return sizeof(Buffer); }

// a.cpp, compiled with -DLARGE
#include "buffer.h"
int from_a() { return capacity(); }

// b.cpp, compiled without it
#include "buffer.h"
int from_b() { return capacity(); }

A main prints from_a() and from_b(). With GCC 15 on Linux:

BuildPrints
-O0, linked as main.o a.o b.o4096 4096
-O0, linked as main.o b.o a.o64 64
-O2, either order4096 64

Three builds of the same files give three programs. At -O0 the first object file the linker reads wins. At -O2 each file inlines its own copy, so there is no single copy left to pick. An implicit operator= on Buffer is generated the same way, so it would copy 4096 bytes in one file and 64 in the other.

The standard calls this a violation of the One Definition Rule: a program is ill-formed if two files define the same inline function differently. It also says "no diagnostic required", so neither the compiler nor the linker has to report it. The same holds for templates: if the code a template means depends on where it is instantiated, the program is ill-formed, with no diagnostic required.

Modules help, but not completely

C++20 modules remove the example above: a module is compiled once, so a macro can no longer give two files two versions of it. They do not remove the rule. A template is still instantiated in the modules that use it, and what it means there can still depend on which declarations that module can reach. When two modules bring in the same definition, the compiler has to merge them, and which one it sees first depends on the order the modules are loaded. Clang reports some mismatches as "different definitions in different modules". The standard still does not require it to.

Other languages with the same problem

The pattern is not specific to C++. It appears wherever something is chosen at the place it is used, and nothing makes every place choose the same way.

Languages that rule it out

Rust generates generic code on use too, but the result cannot depend on where. Three rules make sure of that:

  1. One implementation per type and trait. An impl must be in the crate that defines the trait or the type (the orphan rule), and two impls may not overlap. A whole program has at most one implementation of any trait for any type, so there is nothing to choose.
  2. Generated code depends only on the type. Clone and Copy are asked for with #[derive] where the type is defined, not created where they are used.
  3. Generic code is checked once. A generic function is type-checked against its bounds, not once per type it is used with, so an error cannot appear in one use and not another.

Go and C# also check generic code once. Zig is a useful middle case: it analyses a function only if something refers to it, so an unused function with a type error compiles. That depends on which functions are reachable, not on the order anything is built, so it is still deterministic.

With these rules, generating code only on use is purely an optimization: it saves work, and it cannot change what the program does.

Where Vx stands

Vx compiles modules and functions in parallel with vxc -j, so this question is not academic. We checked each rule against the compiler as it is today.

The parallel build is the same program. A test compiles a program two modules deep at one thread and at four, and requires the generated MLIR to be the same bytes. The checking and the lowering of modules run concurrently at four threads, so module order is part of what the test compares.

Unused library functions are not checked, the way Zig does it. A function in the program's own file is always checked. A function in an imported module is checked only if the program uses it, so a library's type error appears only in programs that call the function. The error names the program's use: in `from_left`, imported from `left` and used by this program. This depends on what is reachable, not on build order, and the test for it runs with one thread, with four, and on both of Vx's code generators.

Two implementations of one trait for one type are refused, but too late. Here two modules implement the same trait for the same type, one returning 1 and one returning 2:

// shape.vx
struct Point { x : i32, y : i32, }
trait Describe { fn describe(self : &Self) -> i32; }
fn make() -> Point { return Point { x : 1, y : 2 }; }

// left.vx
import shape;
impl Describe for Point { fn describe(self : &Point) -> i32 { return 1; } }
fn from_left(p : &Point) -> i32 { return p.describe(); }

// right.vx: the same, but it returns 2 and the function is from_right

// main.vx
import shape;
import left;
import right;
fn main() -> i32 {
  let p = make();
  print(from_left(&p));
  print(from_right(&p));
  return 0;
}

Vx never picks one silently. The program is refused with E3035 in both import orders, at one thread and at four, and on both code generators, with the same errors each time. (A -j build prints them in a slightly different order from a plain build, but in the same order at every thread count.)

error[E3035]: in `from_left`, imported from `left` and used by this program: Method 'describe'
on 'Point' is defined by more than one impl (Describe, Describe); name the trait to choose one,
as in 'Describe::describe(..)'

But this is GHC's behaviour, not Rust's. The two impls themselves are accepted: delete the two calls to describe and the program compiles. The error lands on whichever function calls the method, which may be in a third module whose author did nothing wrong. And the help is wrong for this case: both impls are of Describe, so naming the trait gives the same error. Both problems are filed as #1443. The fix is to report the second impl where it is written. Whether Vx should also adopt Rust's orphan rule, which makes the cross-module case impossible to write, is a larger decision.

Vx is Apache 2.0 with the LLVM exception — install it and try the examples.